This commit is contained in:
longdayi 2024-12-31 15:57:32 +08:00
parent 3e47150b1a
commit cfa4be626d
104 changed files with 3193 additions and 833 deletions

View File

@ -1,4 +1,4 @@
DATABASE_URL="postgresql://root:Letusdoit000@localhost:5432/defender_app?schema=public"
DATABASE_URL="postgresql://root:Letusdoit000@localhost:5432/app?schema=public"
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=Letusdoit000

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { AuthSchema, ObjectModelMethodSchema } from '@nicestack/common';
import { AuthSchema } from '@nicestack/common';
import { AuthService } from './auth.service';
@Injectable()

View File

@ -8,6 +8,7 @@ import {
DelegateFuncs,
UpdateOrderArgs,
TransactionType,
SelectArgs,
} from './base.type';
import {
NotFoundException,
@ -153,7 +154,7 @@ export class BaseService<
async create(args: A['create'], params?: any): Promise<R['create']> {
try {
if (this.enableOrder && !(args as any).data.order) {
// 查找当前最大的 order 值
const maxOrderItem = await this.getModel(params?.tx).findFirst({
@ -396,7 +397,6 @@ export class BaseService<
}) as Promise<R['update'][]>;
} catch (error) {
this.handleError(error, 'delete');
throw error; // Re-throw the error to be handled higher up
}
}
/**
@ -433,16 +433,19 @@ export class BaseService<
* const users = await service.findManyWithPagination({ page: 1, pageSize: 10, where: { isActive: true } });
*/
async findManyWithPagination(args: {
page: number;
pageSize: number;
where?: WhereArgs<A['findUnique']>;
page?: number;
pageSize?: number;
where?: WhereArgs<A['findMany']>;
select?: SelectArgs<A['findMany']>
}): Promise<R['findMany']> {
const { page, pageSize, where } = args;
const { page = 1, pageSize = 10, where, select } = args;
try {
return this.getModel().findMany({
where,
select,
skip: (page - 1) * pageSize,
take: pageSize,
} as any) as Promise<R['findMany']>;
} catch (error) {
this.handleError(error, 'read');
@ -456,21 +459,11 @@ export class BaseService<
*/
async findManyWithCursor(
args: A['findMany'],
): Promise<{ items: R['findMany']; nextCursor: string }> {
): Promise<{ items: R['findMany']; nextCursor: string | null }> {
// 解构查询参数,设置默认每页取10条记录
const { cursor, take = 6, where, orderBy, select } = args as any;
try {
/**
*
* @description :
* 1. where -
* 2. orderBy - ,,updatedAt和id的降序作为稳定排序
* 3. select -
* 4. take - n+1,
* 5. cursor - ,updatedAt和id的组合
*/
const items = (await this.getModel().findMany({
where: where,
orderBy: [{ ...orderBy }, { updatedAt: 'desc' }, { id: 'desc' }],

View File

@ -22,8 +22,8 @@ export type DelegateReturnTypes<T> = {
[K in keyof T]: T[K] extends (args: any) => Promise<infer R> ? R : never;
};
export type WhereArgs<T> = T extends { where: infer W } ? W : never;
export type SelectArgs<T> = T extends { select: infer S } ? S : never;
export type WhereArgs<T> = T extends { where?: infer W } ? W : never;
export type SelectArgs<T> = T extends { select?: infer S } ? S : never;
export type DataArgs<T> = T extends { data: infer D } ? D : never;
export type IncludeArgs<T> = T extends { include: infer I } ? I : never;
export type OrderByArgs<T> = T extends { orderBy: infer O } ? O : never;

View File

@ -1,5 +1,5 @@
import { Logger } from "@nestjs/common";
import { UserProfile, db, getUniqueItems, ObjectWithId, Prisma, RowModelRequest } from "@nicestack/common";
import { UserProfile, db, RowModelRequest } from "@nicestack/common";
import { LogicalCondition, OperatorType, SQLBuilder } from './sql-builder';
export interface GetRowOptions {
id?: string;
@ -14,19 +14,21 @@ export abstract class RowModelService {
]);
protected logger = new Logger(this.tableName);
protected constructor(protected tableName: string) { }
protected async getRowDto(row: ObjectWithId, staff?: UserProfile): Promise<any> {
protected async getRowDto(row: any, staff?: UserProfile): Promise<any> {
return row;
}
protected async getRowsSqlWrapper(sql: string, request?: RowModelRequest, staff?: UserProfile) {
return SQLBuilder.join([sql, this.getLimitSql(request)])
if (request)
return SQLBuilder.join([sql, this.getLimitSql(request)])
return sql
}
protected getLimitSql(request: RowModelRequest) {
return SQLBuilder.limit(request.endRow - request.startRow, request.startRow)
}
abstract createJoinSql(request?: RowModelRequest): string[];
async getRows(request: RowModelRequest, staff?: UserProfile): Promise<{ rowCount: number, rowData: any[] }> {
async getRows(request: RowModelRequest, staff?: UserProfile) {
try {
// this.logger.debug('request', request)
let SQL = SQLBuilder.join([
SQLBuilder.select(this.getRowSelectCols(request)),
SQLBuilder.from(this.tableName),
@ -39,17 +41,13 @@ export abstract class RowModelService {
this.logger.debug('getrows', SQL)
const results: any[] = await db.$queryRawUnsafe(SQL) || [];
const results: any[] = await db?.$queryRawUnsafe(SQL) || [];
let rowDataDto = await Promise.all(results.map(row => this.getRowDto(row, staff)))
// if (this.getGroupByColumns(request).length === 0)
// rowDataDto = getUniqueItems(rowDataDto, "id")
// this.logger.debug('result', results.length, this.getRowCount(request, rowDataDto))
return { rowCount: this.getRowCount(request, rowDataDto) || 0, rowData: rowDataDto };
} catch (error: any) {
this.logger.error('Error executing getRows:', error);
// throw new Error(`Failed to get rows: ${error.message}`);
}
}
getRowCount(request: RowModelRequest, results: any[]) {
@ -79,7 +77,7 @@ export abstract class RowModelService {
}
protected createGetRowsFilters(request: RowModelRequest, staff?: UserProfile): LogicalCondition {
let groupConditions = []
let groupConditions: LogicalCondition[] = []
if (this.isDoingTreeGroup(request)) {
groupConditions = [
{
@ -89,7 +87,7 @@ export abstract class RowModelService {
}
]
} else {
groupConditions = request.groupKeys.map((key, index) => ({
groupConditions = request?.groupKeys?.map((key, index) => ({
field: request.rowGroupCols[index].field,
op: "equals" as OperatorType,
value: key
@ -124,9 +122,9 @@ export abstract class RowModelService {
}
protected createGroupingRowSelect(request: RowModelRequest, wrapperSql: boolean = false): string[] {
const { rowGroupCols, valueCols, groupKeys } = request;
const colsToSelect = [];
const colsToSelect: string[] = [];
const rowGroupCol = rowGroupCols[groupKeys.length];
const rowGroupCol = rowGroupCols[groupKeys!.length];
if (rowGroupCol) {
colsToSelect.push(`${rowGroupCol.field} AS ${rowGroupCol.field.replace('.', '_')}`);
}
@ -139,7 +137,7 @@ export abstract class RowModelService {
getGroupByColumns(request: RowModelRequest): string[] {
return this.isDoingGroup(request)
? [request.rowGroupCols[request.groupKeys.length]?.field]
? [request.rowGroupCols[request.groupKeys!.length]?.field]
: [];
}
@ -206,7 +204,7 @@ export abstract class RowModelService {
SQLBuilder.where(this.createGetRowsFilters(request)),
SQLBuilder.groupBy(this.buildAggGroupBy())
]);
const result = await db.$queryRawUnsafe(SQL);
const result: any[] = await db.$queryRawUnsafe(SQL);
return result[0];
} catch (error) {
this.logger.error('Error executing query:', error);

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CourseRouter } from './course.router';
import { CourseService } from './course.service';
import { TrpcService } from '@server/trpc/trpc.service';
@Module({
providers: [CourseRouter, CourseService, TrpcService],
exports: [CourseRouter, CourseService]
})
export class CourseModule { }

View File

@ -0,0 +1,86 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nicestack/common';
import { CourseService } from './course.service';
import { z, ZodType } from 'zod';
const CourseCreateArgsSchema: ZodType<Prisma.CourseCreateArgs> = z.any()
const CourseUpdateArgsSchema: ZodType<Prisma.CourseUpdateArgs> = z.any()
const CourseCreateManyInputSchema: ZodType<Prisma.CourseCreateManyInput> = z.any()
const CourseDeleteManyArgsSchema: ZodType<Prisma.CourseDeleteManyArgs> = z.any()
const CourseFindManyArgsSchema: ZodType<Prisma.CourseFindManyArgs> = z.any()
const CourseFindFirstArgsSchema: ZodType<Prisma.CourseFindFirstArgs> = z.any()
const CourseWhereInputSchema: ZodType<Prisma.CourseWhereInput> = z.any()
const CourseSelectSchema: ZodType<Prisma.CourseSelect> = z.any()
@Injectable()
export class CourseRouter {
constructor(
private readonly trpc: TrpcService,
private readonly courseService: CourseService,
) { }
router = this.trpc.router({
create: this.trpc.protectProcedure
.input(CourseCreateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.courseService.create(input, staff);
}),
update: this.trpc.protectProcedure
.input(CourseUpdateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.courseService.update(input, staff);
}),
createMany: this.trpc.protectProcedure.input(z.array(CourseCreateManyInputSchema))
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.courseService.createMany({ data: input }, staff);
}),
deleteMany: this.trpc.procedure
.input(CourseDeleteManyArgsSchema)
.mutation(async ({ input }) => {
return await this.courseService.deleteMany(input);
}),
findFirst: this.trpc.procedure
.input(CourseFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.courseService.findFirst(input);
}),
softDeleteByIds: this.trpc.protectProcedure
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
.mutation(async ({ input }) => {
return this.courseService.softDeleteByIds(input.ids);
}),
updateOrder: this.trpc.protectProcedure
.input(UpdateOrderSchema)
.mutation(async ({ input }) => {
return this.courseService.updateOrder(input);
}),
findMany: this.trpc.procedure
.input(CourseFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.courseService.findMany(input);
}),
findManyWithCursor: this.trpc.protectProcedure
.input(z.object({
cursor: z.any().nullish(),
take: z.number().optional(),
where: CourseWhereInputSchema.optional(),
select: CourseSelectSchema.optional()
}))
.query(async ({ ctx, input }) => {
return await this.courseService.findManyWithCursor(input);
}),
findManyWithPagination: this.trpc.procedure
.input(z.object({
page: z.number(),
pageSize: z.number().optional(),
where: CourseWhereInputSchema.optional(),
select: CourseSelectSchema.optional()
})) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.courseService.findManyWithPagination(input);
}),
});
}

View File

@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import { BaseService } from '../base/base.service';
import {
UserProfile,
db,
ObjectType,
Prisma,
} from '@nicestack/common';
@Injectable()
export class CourseService extends BaseService<Prisma.CourseDelegate> {
constructor() {
super(db, ObjectType.COURSE);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { EnrollmentRouter } from './enrollment.router';
import { EnrollmentService } from './enrollment.service';
@Module({
exports: [EnrollmentRouter, EnrollmentService],
providers: [EnrollmentRouter, EnrollmentService]
})
export class EnrollmentModule { }

View File

@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nicestack/common';
import { EnrollmentService } from './enrollment.service';
import { z, ZodType } from 'zod';
const EnrollmentCreateArgsSchema: ZodType<Prisma.EnrollmentCreateArgs> = z.any()
const EnrollmentCreateManyInputSchema: ZodType<Prisma.EnrollmentCreateManyInput> = z.any()
const EnrollmentDeleteManyArgsSchema: ZodType<Prisma.EnrollmentDeleteManyArgs> = z.any()
const EnrollmentFindManyArgsSchema: ZodType<Prisma.EnrollmentFindManyArgs> = z.any()
const EnrollmentFindFirstArgsSchema: ZodType<Prisma.EnrollmentFindFirstArgs> = z.any()
const EnrollmentWhereInputSchema: ZodType<Prisma.EnrollmentWhereInput> = z.any()
const EnrollmentSelectSchema: ZodType<Prisma.EnrollmentSelect> = z.any()
@Injectable()
export class EnrollmentRouter {
constructor(
private readonly trpc: TrpcService,
private readonly enrollmentService: EnrollmentService,
) { }
router = this.trpc.router({
create: this.trpc.protectProcedure
.input(EnrollmentCreateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.enrollmentService.create(input, staff);
}),
createMany: this.trpc.protectProcedure.input(z.array(EnrollmentCreateManyInputSchema))
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.enrollmentService.createMany({ data: input }, staff);
}),
deleteMany: this.trpc.procedure
.input(EnrollmentDeleteManyArgsSchema)
.mutation(async ({ input }) => {
return await this.enrollmentService.deleteMany(input);
}),
findFirst: this.trpc.procedure
.input(EnrollmentFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.enrollmentService.findFirst(input);
}),
softDeleteByIds: this.trpc.protectProcedure
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
.mutation(async ({ input }) => {
return this.enrollmentService.softDeleteByIds(input.ids);
}),
updateOrder: this.trpc.protectProcedure
.input(UpdateOrderSchema)
.mutation(async ({ input }) => {
return this.enrollmentService.updateOrder(input);
}),
findMany: this.trpc.procedure
.input(EnrollmentFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.enrollmentService.findMany(input);
}),
findManyWithCursor: this.trpc.protectProcedure
.input(z.object({
cursor: z.any().nullish(),
take: z.number().nullish(),
where: EnrollmentWhereInputSchema.nullish(),
select: EnrollmentSelectSchema.nullish()
}))
.query(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.enrollmentService.findManyWithCursor(input);
}),
});
}

View File

@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { BaseService } from '../base/base.service';
import {
UserProfile,
db,
ObjectType,
Prisma,
} from '@nicestack/common';
@Injectable()
export class EnrollmentService extends BaseService<Prisma.EnrollmentDelegate> {
constructor() {
super(db, ObjectType.COURSE);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LectureRouter } from './lecture.router';
import { LectureService } from './lecture.service';
import { TrpcService } from '@server/trpc/trpc.service';
@Module({
providers: [LectureRouter, LectureService, TrpcService],
exports: [LectureRouter, LectureService]
})
export class LectureModule { }

View File

@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nicestack/common';
import { LectureService } from './lecture.service';
import { z, ZodType } from 'zod';
const LectureCreateArgsSchema: ZodType<Prisma.LectureCreateArgs> = z.any()
const LectureCreateManyInputSchema: ZodType<Prisma.LectureCreateManyInput> = z.any()
const LectureDeleteManyArgsSchema: ZodType<Prisma.LectureDeleteManyArgs> = z.any()
const LectureFindManyArgsSchema: ZodType<Prisma.LectureFindManyArgs> = z.any()
const LectureFindFirstArgsSchema: ZodType<Prisma.LectureFindFirstArgs> = z.any()
const LectureWhereInputSchema: ZodType<Prisma.LectureWhereInput> = z.any()
const LectureSelectSchema: ZodType<Prisma.LectureSelect> = z.any()
@Injectable()
export class LectureRouter {
constructor(
private readonly trpc: TrpcService,
private readonly lectureService: LectureService,
) { }
router = this.trpc.router({
create: this.trpc.protectProcedure
.input(LectureCreateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.lectureService.create(input, staff);
}),
createMany: this.trpc.protectProcedure.input(z.array(LectureCreateManyInputSchema))
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.lectureService.createMany({ data: input }, staff);
}),
deleteMany: this.trpc.procedure
.input(LectureDeleteManyArgsSchema)
.mutation(async ({ input }) => {
return await this.lectureService.deleteMany(input);
}),
findFirst: this.trpc.procedure
.input(LectureFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.lectureService.findFirst(input);
}),
softDeleteByIds: this.trpc.protectProcedure
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
.mutation(async ({ input }) => {
return this.lectureService.softDeleteByIds(input.ids);
}),
updateOrder: this.trpc.protectProcedure
.input(UpdateOrderSchema)
.mutation(async ({ input }) => {
return this.lectureService.updateOrder(input);
}),
findMany: this.trpc.procedure
.input(LectureFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.lectureService.findMany(input);
}),
findManyWithCursor: this.trpc.protectProcedure
.input(z.object({
cursor: z.any().nullish(),
take: z.number().nullish(),
where: LectureWhereInputSchema.nullish(),
select: LectureSelectSchema.nullish()
}))
.query(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.lectureService.findManyWithCursor(input);
}),
});
}

View File

@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { BaseService } from '../base/base.service';
import {
UserProfile,
db,
ObjectType,
Prisma,
} from '@nicestack/common';
@Injectable()
export class LectureService extends BaseService<Prisma.LectureDelegate> {
constructor() {
super(db, ObjectType.COURSE);
}
}

View File

@ -53,7 +53,7 @@ export class MessageController {
visits: {
none: {
id: staffId,
visitType: VisitType.READED
type: VisitType.READED
},
},
receivers: {
@ -92,7 +92,7 @@ export class MessageController {
visits: {
none: {
id: staffId,
visitType: VisitType.READED
type: VisitType.READED
},
},
receivers: {

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { MessageService } from './message.service';
import { ChangedRows, Prisma } from '@nicestack/common';
import { Prisma } from '@nicestack/common';
import { z, ZodType } from 'zod';
const MessageUncheckedCreateInputSchema: ZodType<Prisma.MessageUncheckedCreateInput> = z.any()
const MessageWhereInputSchema: ZodType<Prisma.MessageWhereInput> = z.any()

View File

@ -9,7 +9,7 @@ export class MessageService extends BaseService<Prisma.MessageDelegate> {
super(db, ObjectType.MESSAGE);
}
async create(args: Prisma.MessageCreateArgs, params?: { tx?: Prisma.MessageDelegate, staff?: UserProfile }) {
args.data.senderId = params?.staff?.id;
args.data!.senderId = params?.staff?.id;
args.include = {
receivers: {
select: { id: true, registerToken: true, username: true }
@ -46,7 +46,7 @@ export class MessageService extends BaseService<Prisma.MessageDelegate> {
visits: {
none: {
visitorId: staff?.id,
visitType: VisitType.READED
type: VisitType.READED
}
}
}

View File

@ -8,7 +8,7 @@ export async function setMessageRelation(
(await db.visit.count({
where: {
messageId: data.id,
visitType: VisitType.READED,
type: VisitType.READED,
visitorId: staff?.id,
},
})) > 0;

View File

@ -7,83 +7,5 @@ import { db } from '@nicestack/common';
@Controller('post')
export class PostController {
constructor(private readonly postService: PostService) { }
@UseGuards(AuthGuard)
@Get('find-last-one')
async findLastOne(@Query('trouble-id') troubleId: string) {
try {
const result = await this.postService.findFirst({
where: { referenceId: troubleId },
orderBy: { createdAt: 'desc' }
});
return {
data: result,
errmsg: 'success',
errno: 0,
};
} catch (e) {
return {
data: {},
errmsg: (e as any)?.message || 'error',
errno: 1,
};
}
}
@UseGuards(AuthGuard)
@Get('find-all')
async findAll(@Query('trouble-id') troubleId: string) {
try {
const result = await db.post.findMany({
where: {
OR: [{ referenceId: troubleId }],
},
orderBy: { createdAt: 'desc' },
select: {
title: true,
content: true,
attachments: true,
type: true,
author: {
select: {
id: true,
showname: true,
username: true,
},
},
},
});
return {
data: result,
errmsg: 'success',
errno: 0,
};
} catch (e) {
return {
data: {},
errmsg: (e as any)?.message || 'error',
errno: 1,
};
}
}
@UseGuards(AuthGuard)
@Get('count')
async count(@Query('trouble-id') troubleId: string) {
try {
const result = await db.post.count({
where: {
OR: [{ referenceId: troubleId }],
},
});
return {
data: result,
errmsg: 'success',
errno: 0,
};
} catch (e) {
return {
data: {},
errmsg: (e as any)?.message || 'error',
errno: 1,
};
}
}
}

View File

@ -28,7 +28,7 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
args: Prisma.PostCreateArgs,
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
) {
args.data.authorId = params?.staff.id;
args.data.authorId = params?.staff?.id;
const result = await super.create(args);
EventBus.emit('dataChanged', {
type: ObjectType.POST,
@ -38,12 +38,12 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
return result;
}
async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) {
args.data.authorId = staff.id;
args.data.authorId = staff?.id;
return super.update(args);
}
async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
if (!args.where) args.where = {}
args.where.OR = await this.preFilter(args.where.OR, staff);
// console.log(`findwithcursor_post ${JSON.stringify(args.where)}`)
return this.wrapResult(super.findManyWithCursor(args), async (result) => {
let { items } = result;
@ -57,7 +57,7 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
});
}
protected async setPerms(data: Post, staff: UserProfile) {
protected async setPerms(data: Post, staff?: UserProfile) {
if (!staff) return;
const perms: ResPerm = {
delete: false,
@ -93,7 +93,8 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
const outOR = OR ? [...OR, ...preFilter].filter(Boolean) : preFilter;
return outOR?.length > 0 ? outOR : undefined;
}
async getPostPreFilter(staff: UserProfile) {
async getPostPreFilter(staff?: UserProfile) {
if (!staff) return
const { deptId, domainId } = staff;
if (
staff.permissions.includes(RolePerms.READ_ANY_POST) ||

View File

@ -22,17 +22,17 @@ export async function setPostRelation(params: { data: Post, staff?: UserProfile
(await db.visit.count({
where: {
postId: data.id,
visitType: VisitType.READED,
type: VisitType.READED,
visitorId: staff?.id,
},
})) > 0;
const readedCount = await db.visit.count({
where: {
postId: data.id,
visitType: VisitType.READED,
type: VisitType.READED,
},
});
// const trouble = await getTroubleWithRelation(data.referenceId, staff)
Object.assign(data, {
readed,
readedCount,

View File

@ -1,44 +1,88 @@
import { Injectable } from "@nestjs/common";
import { TrpcService } from "@server/trpc/trpc.service";
import { RoleService } from "./role.service";
import { RoleMethodSchema } from "@nicestack/common";
import { z } from "zod";
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nicestack/common';
import { RoleService } from './role.service';
import { z, ZodType } from 'zod';
const RoleCreateArgsSchema: ZodType<Prisma.RoleCreateArgs> = z.any()
const RoleUpdateArgsSchema: ZodType<Prisma.RoleUpdateArgs> = z.any()
const RoleCreateManyInputSchema: ZodType<Prisma.RoleCreateManyInput> = z.any()
const RoleDeleteManyArgsSchema: ZodType<Prisma.RoleDeleteManyArgs> = z.any()
const RoleFindManyArgsSchema: ZodType<Prisma.RoleFindManyArgs> = z.any()
const RoleFindFirstArgsSchema: ZodType<Prisma.RoleFindFirstArgs> = z.any()
const RoleWhereInputSchema: ZodType<Prisma.RoleWhereInput> = z.any()
const RoleSelectSchema: ZodType<Prisma.RoleSelect> = z.any()
const RoleUpdateInputSchema: ZodType<Prisma.RoleUpdateInput> = z.any();
@Injectable()
export class RoleRouter {
constructor(
private readonly trpc: TrpcService,
private readonly roleService: RoleService
private readonly roleService: RoleService,
) { }
router = this.trpc.router({
create: this.trpc.protectProcedure.input(RoleMethodSchema.create).mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.roleService.create(input);
}),
deleteMany: this.trpc.protectProcedure.input(RoleMethodSchema.deleteMany).mutation(async ({ input }) => {
return await this.roleService.deleteMany(input);
}),
update: this.trpc.protectProcedure.input(RoleMethodSchema.update).mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.roleService.update(input);
}),
paginate: this.trpc.protectProcedure.input(RoleMethodSchema.paginate).query(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.roleService.paginate(input);
}),
findById: this.trpc.protectProcedure
.input(z.object({ id: z.string().nullish() }))
.query(async ({ ctx, input }) => {
create: this.trpc.protectProcedure
.input(RoleCreateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.roleService.findById(input.id);
return await this.roleService.create(input, staff);
}),
update: this.trpc.protectProcedure
.input(RoleUpdateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.roleService.update(input, staff);
}),
createMany: this.trpc.protectProcedure.input(z.array(RoleCreateManyInputSchema))
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.roleService.createMany({ data: input }, staff);
}),
softDeleteByIds: this.trpc.protectProcedure
.input(
z.object({
ids: z.array(z.string()),
data: RoleUpdateInputSchema.optional()
}),
)
.mutation(async ({ input }) => {
return await this.roleService.softDeleteByIds(input.ids, input.data);
}),
findFirst: this.trpc.procedure
.input(RoleFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.roleService.findFirst(input);
}),
updateOrder: this.trpc.protectProcedure
.input(UpdateOrderSchema)
.mutation(async ({ input }) => {
return this.roleService.updateOrder(input);
}),
findMany: this.trpc.procedure
.input(RoleMethodSchema.findMany) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.input(RoleFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.roleService.findMany(input);
})
}
)
}),
findManyWithCursor: this.trpc.protectProcedure
.input(z.object({
cursor: z.any().nullish(),
take: z.number().optional(),
where: RoleWhereInputSchema.optional(),
select: RoleSelectSchema.optional()
}))
.query(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.roleService.findManyWithCursor(input);
}),
findManyWithPagination: this.trpc.procedure
.input(z.object({
page: z.number(),
pageSize: z.number().optional(),
where: RoleWhereInputSchema.optional(),
select: RoleSelectSchema.optional()
})) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.roleService.findManyWithPagination(input);
}),
});
}

View File

@ -0,0 +1,47 @@
import { db, ObjectType, RowModelRequest, RowRequestSchema, UserProfile } from "@nicestack/common";
import { RowCacheService } from "../base/row-cache.service";
import { isFieldCondition, LogicalCondition } from "../base/sql-builder";
import { z } from "zod";
export class RoleRowService extends RowCacheService {
protected createGetRowsFilters(
request: z.infer<typeof RowRequestSchema>,
staff?: UserProfile
) {
const condition = super.createGetRowsFilters(request)
if (isFieldCondition(condition))
return {}
const baseModelCondition: LogicalCondition[] = [{
field: `${this.tableName}.deleted_at`,
op: "blank",
type: "date"
}]
condition.AND = [...baseModelCondition, ...condition.AND!]
return condition
}
createUnGroupingRowSelect(): string[] {
return [
`${this.tableName}.id AS id`,
`${this.tableName}.name AS name`,
`${this.tableName}.system AS system`,
`${this.tableName}.permissions AS permissions`
];
}
protected async getRowDto(data: any, staff?: UserProfile): Promise<any> {
if (!data.id)
return data
const roleMaps = await db.roleMap.findMany({
where: {
roleId: data.id
}
})
const deptIds = roleMaps.filter(item => item.objectType === ObjectType.DEPARTMENT).map(roleMap => roleMap.objectId)
const staffIds = roleMaps.filter(item => item.objectType === ObjectType.STAFF).map(roleMap => roleMap.objectId)
const depts = await db.department.findMany({ where: { id: { in: deptIds } } })
const staffs = await db.staff.findMany({ where: { id: { in: staffIds } } })
const result = { ...data, depts, staffs }
return result
}
createJoinSql(request?: RowModelRequest): string[] {
return [];
}
}

View File

@ -1,100 +1,10 @@
import { Injectable } from '@nestjs/common';
import { db, RoleMethodSchema, RowModelRequest, UserProfile, RowRequestSchema, ObjectWithId, ObjectType } from "@nicestack/common";
import { DepartmentService } from '@server/models/department/department.service';
import EventBus, { CrudOperation } from '@server/utils/event-bus';
import { TRPCError } from '@trpc/server';
import { RowModelService } from '../base/row-model.service';
import { isFieldCondition, LogicalCondition } from '../base/sql-builder';
import { z } from 'zod';
import { db, RoleMethodSchema, ObjectType, Prisma } from "@nicestack/common";
import { BaseService } from '../base/base.service';
@Injectable()
export class RoleService extends RowModelService {
protected createGetRowsFilters(
request: z.infer<typeof RowRequestSchema>,
staff?: UserProfile
) {
const condition = super.createGetRowsFilters(request)
if (isFieldCondition(condition))
return {}
const baseModelCondition: LogicalCondition[] = [{
field: `${this.tableName}.deleted_at`,
op: "blank",
type: "date"
}]
condition.AND = [...baseModelCondition, ...condition.AND]
return condition
}
createUnGroupingRowSelect(): string[] {
return [
`${this.tableName}.id AS id`,
`${this.tableName}.name AS name`,
`${this.tableName}.system AS system`,
`${this.tableName}.permissions AS permissions`
];
}
protected async getRowDto(data: ObjectWithId, staff?: UserProfile): Promise<any> {
if (!data.id)
return data
const roleMaps = await db.roleMap.findMany({
where: {
roleId: data.id
}
})
const deptIds = roleMaps.filter(item => item.objectType === ObjectType.DEPARTMENT).map(roleMap => roleMap.objectId)
const staffIds = roleMaps.filter(item => item.objectType === ObjectType.STAFF).map(roleMap => roleMap.objectId)
const depts = await db.department.findMany({ where: { id: { in: deptIds } } })
const staffs = await db.staff.findMany({ where: { id: { in: staffIds } } })
const result = { ...data, depts, staffs }
return result
}
createJoinSql(request?: RowModelRequest): string[] {
return [];
}
constructor(
private readonly departmentService: DepartmentService
) {
super("role")
}
/**
*
* @param data
* @returns
*/
async create(data: z.infer<typeof RoleMethodSchema.create>) {
const result = await db.role.create({ data })
EventBus.emit('dataChanged', {
type: ObjectType.ROLE,
operation: CrudOperation.CREATED,
data: result,
});
return result
}
async findById(id: string) {
return await db.role.findUnique({
where: {
id
}
})
}
/**
*
* @param data
* @returns
*/
async update(data: z.infer<typeof RoleMethodSchema.update>) {
const { id, ...others } = data;
// 开启事务
const result = await db.role.update({
where: { id },
data: { ...others }
});
EventBus.emit('dataChanged', {
type: ObjectType.ROLE,
operation: CrudOperation.UPDATED,
data: result,
});
return result
export class RoleService extends BaseService<Prisma.RoleDelegate> {
constructor() {
super(db, ObjectType.ROLE)
}
/**
*
@ -102,79 +12,15 @@ export class RoleService extends RowModelService {
* @returns
* @throws ID
*/
async deleteMany(data: z.infer<typeof RoleMethodSchema.deleteMany>) {
const { ids } = data;
if (!ids || ids.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No IDs provided for deletion.'
});
}
// 开启事务
const result = await db.$transaction(async (prisma) => {
await prisma.roleMap.deleteMany({
where: {
roleId: {
in: ids
}
}
});
const deletedRoles = await prisma.role.deleteMany({
where: {
id: { in: ids }
}
});
return { success: true, count: deletedRoles.count };
});
EventBus.emit('dataChanged', {
type: ObjectType.ROLE,
operation: CrudOperation.DELETED,
data: result,
});
return result
}
/**
*
* @param data
* @returns
*/
async paginate(data: z.infer<typeof RoleMethodSchema.paginate>) {
const { page, pageSize } = data;
const [items, totalCount] = await Promise.all([
db.role.findMany({
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { name: "asc" },
where: { deletedAt: null },
include: {
roleMaps: true,
}
}),
db.role.count({ where: { deletedAt: null } }),
]);
const result = { items, totalCount };
return result;
}
/**
*
* @param data
* @returns
*/
async findMany(data: z.infer<typeof RoleMethodSchema.findMany>) {
const { keyword = '' } = data
return await db.role.findMany({
async softDeleteByIds(ids: string[], data?: Prisma.RoleUpdateInput) {
await db.roleMap.deleteMany({
where: {
deletedAt: null,
OR: [
{
name: {
contains: keyword
}
}
]
},
orderBy: { createdAt: "asc" },
take: 10
})
roleId: {
in: ids
}
}
});
return await super.softDeleteByIds(ids, data)
}
}

View File

@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import {
ChangedRows,
ObjectType,
RoleMapMethodSchema,
} from '@nicestack/common';

View File

@ -6,7 +6,6 @@ import {
Prisma,
RowModelRequest,
UserProfile,
ObjectWithId,
} from '@nicestack/common';
import { DepartmentService } from '@server/models/department/department.service';
import { TRPCError } from '@trpc/server';
@ -66,7 +65,7 @@ export class RoleMapService extends RowModelService {
}
protected async getRowDto(
row: ObjectWithId,
row: any,
staff?: UserProfile,
): Promise<any> {
if (!row.id) return row;

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ResourceRouter } from './resource.router';
import { ResourceService } from './resource.service';
@Module({
exports: [ResourceRouter, ResourceService],
providers: [ResourceRouter, ResourceService],
})
export class ResourceModule { }

View File

@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nicestack/common';
import { ResourceService } from './resource.service';
import { z, ZodType } from 'zod';
const ResourceCreateArgsSchema: ZodType<Prisma.ResourceCreateArgs> = z.any()
const ResourceCreateManyInputSchema: ZodType<Prisma.ResourceCreateManyInput> = z.any()
const ResourceDeleteManyArgsSchema: ZodType<Prisma.ResourceDeleteManyArgs> = z.any()
const ResourceFindManyArgsSchema: ZodType<Prisma.ResourceFindManyArgs> = z.any()
const ResourceFindFirstArgsSchema: ZodType<Prisma.ResourceFindFirstArgs> = z.any()
const ResourceWhereInputSchema: ZodType<Prisma.ResourceWhereInput> = z.any()
const ResourceSelectSchema: ZodType<Prisma.ResourceSelect> = z.any()
@Injectable()
export class ResourceRouter {
constructor(
private readonly trpc: TrpcService,
private readonly resourceService: ResourceService,
) { }
router = this.trpc.router({
create: this.trpc.protectProcedure
.input(ResourceCreateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.resourceService.create(input, staff);
}),
createMany: this.trpc.protectProcedure.input(z.array(ResourceCreateManyInputSchema))
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.resourceService.createMany({ data: input }, staff);
}),
deleteMany: this.trpc.procedure
.input(ResourceDeleteManyArgsSchema)
.mutation(async ({ input }) => {
return await this.resourceService.deleteMany(input);
}),
findFirst: this.trpc.procedure
.input(ResourceFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.resourceService.findFirst(input);
}),
softDeleteByIds: this.trpc.protectProcedure
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
.mutation(async ({ input }) => {
return this.resourceService.softDeleteByIds(input.ids);
}),
updateOrder: this.trpc.protectProcedure
.input(UpdateOrderSchema)
.mutation(async ({ input }) => {
return this.resourceService.updateOrder(input);
}),
findMany: this.trpc.procedure
.input(ResourceFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.resourceService.findMany(input);
}),
findManyWithCursor: this.trpc.protectProcedure
.input(z.object({
cursor: z.any().nullish(),
take: z.number().nullish(),
where: ResourceWhereInputSchema.nullish(),
select: ResourceSelectSchema.nullish()
}))
.query(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.resourceService.findManyWithCursor(input);
}),
});
}

View File

@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { BaseService } from '../base/base.service';
import {
UserProfile,
db,
ObjectType,
Prisma,
} from '@nicestack/common';
@Injectable()
export class ResourceService extends BaseService<Prisma.ResourceDelegate> {
constructor() {
super(db, ObjectType.RESOURCE);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SectionRouter } from './section.router';
import { SectionService } from './section.service';
import { TrpcService } from '@server/trpc/trpc.service';
@Module({
exports: [SectionRouter, SectionService],
providers: [SectionRouter, SectionService, TrpcService]
})
export class SectionModule { }

View File

@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nicestack/common';
import { SectionService } from './section.service';
import { z, ZodType } from 'zod';
const SectionCreateArgsSchema: ZodType<Prisma.SectionCreateArgs> = z.any()
const SectionCreateManyInputSchema: ZodType<Prisma.SectionCreateManyInput> = z.any()
const SectionDeleteManyArgsSchema: ZodType<Prisma.SectionDeleteManyArgs> = z.any()
const SectionFindManyArgsSchema: ZodType<Prisma.SectionFindManyArgs> = z.any()
const SectionFindFirstArgsSchema: ZodType<Prisma.SectionFindFirstArgs> = z.any()
const SectionWhereInputSchema: ZodType<Prisma.SectionWhereInput> = z.any()
const SectionSelectSchema: ZodType<Prisma.SectionSelect> = z.any()
@Injectable()
export class SectionRouter {
constructor(
private readonly trpc: TrpcService,
private readonly sectionService: SectionService,
) { }
router = this.trpc.router({
create: this.trpc.protectProcedure
.input(SectionCreateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.sectionService.create(input, staff);
}),
createMany: this.trpc.protectProcedure.input(z.array(SectionCreateManyInputSchema))
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.sectionService.createMany({ data: input }, staff);
}),
deleteMany: this.trpc.procedure
.input(SectionDeleteManyArgsSchema)
.mutation(async ({ input }) => {
return await this.sectionService.deleteMany(input);
}),
findFirst: this.trpc.procedure
.input(SectionFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.sectionService.findFirst(input);
}),
softDeleteByIds: this.trpc.protectProcedure
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
.mutation(async ({ input }) => {
return this.sectionService.softDeleteByIds(input.ids);
}),
updateOrder: this.trpc.protectProcedure
.input(UpdateOrderSchema)
.mutation(async ({ input }) => {
return this.sectionService.updateOrder(input);
}),
findMany: this.trpc.procedure
.input(SectionFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.sectionService.findMany(input);
}),
findManyWithCursor: this.trpc.protectProcedure
.input(z.object({
cursor: z.any().nullish(),
take: z.number().nullish(),
where: SectionWhereInputSchema.nullish(),
select: SectionSelectSchema.nullish()
}))
.query(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.sectionService.findManyWithCursor(input);
}),
});
}

View File

@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { BaseService } from '../base/base.service';
import {
UserProfile,
db,
ObjectType,
Prisma,
} from '@nicestack/common';
@Injectable()
export class SectionService extends BaseService<Prisma.SectionDelegate> {
constructor() {
super(db, ObjectType.SECTION);
}
}

View File

@ -1,17 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import * as ExcelJS from 'exceljs';
import {
TroubleType,
TroubleState,
TransformMethodSchema,
db,
Prisma,
Staff,
GetTroubleLevel,
UserProfile,
TroubleDto,
ObjectType,
RiskState,
} from '@nicestack/common';
import dayjs from 'dayjs';
import * as argon2 from 'argon2';

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { ChangedRows, ObjectType, Prisma } from '@nicestack/common';
import { Prisma } from '@nicestack/common';
import { VisitService } from './visit.service';
import { z, ZodType } from 'zod';
@ -31,7 +31,7 @@ export class VisitRouter {
.mutation(async ({ input }) => {
return await this.visitService.deleteMany(input);
}),
});
}

View File

@ -14,19 +14,19 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
super(db, ObjectType.VISIT);
}
async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) {
const { postId, troubleId, messageId } = args.data;
const { postId, lectureId, messageId } = args.data;
const visitorId = args.data.visitorId || staff?.id;
let result;
const existingVisit = await db.visit.findFirst({
where: {
visitType: args.data.visitType,
type: args.data.type,
visitorId,
OR: [{ postId }, { troubleId }, { messageId }],
OR: [{ postId }, { lectureId }, { messageId }],
},
});
if (!existingVisit) {
result = await super.create(args);
} else if (args.data.visitType === VisitType.READED) {
} else if (args.data.type === VisitType.READED) {
result = await super.update({
where: { id: existingVisit.id },
data: {
@ -36,26 +36,26 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
});
}
if (troubleId && args.data.visitType === VisitType.READED) {
EventBus.emit('updateViewCount', {
objectType: ObjectType.TROUBLE,
id: troubleId,
});
}
// if (troubleId && args.data.type === VisitType.READED) {
// EventBus.emit('updateViewCount', {
// objectType: ObjectType.TROUBLE,
// id: troubleId,
// });
// }
return result;
}
async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) {
const data = Array.isArray(args.data) ? args.data : [args.data];
const updatePromises = [];
const createData = [];
const updatePromises: any[] = [];
const createData: Prisma.VisitCreateManyInput[] = [];
await Promise.all(
data.map(async (item) => {
item.visitorId = item.visitorId || staff?.id;
const { postId, troubleId, messageId, visitorId } = item;
if (staff && !item.visitorId) item.visitorId = staff.id
const { postId, lectureId, messageId, visitorId } = item;
const existingVisit = await db.visit.findFirst({
where: {
visitorId,
OR: [{ postId }, { troubleId }, { messageId }],
OR: [{ postId }, { lectureId }, { messageId }],
},
});

View File

@ -16,8 +16,9 @@ export class PushQueueService implements OnModuleInit {
onModuleInit() {
EventBus.on("dataChanged", async ({ data, type, operation }) => {
if (type === ObjectType.MESSAGE && operation === CrudOperation.CREATED) {
const message = data as Partial<MessageDto>
const message = data as MessageDto
const uniqueStaffs = getUniqueItems(message.receivers, "id")
uniqueStaffs.forEach(item => {
const token = item.registerToken
if (token) {
@ -25,11 +26,11 @@ export class PushQueueService implements OnModuleInit {
registerToken: token,
messageContent: {
data: {
title: message.title,
content: message.content,
title: message.title!,
content: message.content!,
click_action: {
intent: message.intent,
url: message.url
intent: message.intent!,
url: message.url!
}
},
option: message.option as any

View File

@ -5,15 +5,13 @@ interface LoginResponse {
message: string;
authtoken?: string;
}
interface MessagePushResponse {
retcode: string;
message: string;
messageid?: string;
}
interface Notification {
title: string; // 通知标题不超过128字节 / Title of notification (upper limit is 128 bytes)
title?: string; // 通知标题不超过128字节 / Title of notification (upper limit is 128 bytes)
content?: string; // 通知内容不超过256字节 / Content of notification (upper limit is 256 bytes)
click_action?: {
url?: string; // 点击通知栏消息打开指定的URL地址 / URL to open when notification is clicked
@ -47,11 +45,9 @@ export class PushService {
appsecret: this.appsecret,
});
this.handleError(response.data.retcode);
this.authToken = response.data.authtoken;
this.authToken = response.data.authtoken!;
return response.data;
}
async messagePush(
registerToken: string,
messageContent: MessageContent,

View File

@ -2,7 +2,7 @@ import { Injectable, OnModuleInit } from "@nestjs/common";
import { WebSocketType } from "../types";
import { BaseWebSocketServer } from "../base/base-websocket-server";
import EventBus, { CrudOperation } from "@server/utils/event-bus";
import { ObjectType, SocketMsgType, MessageDto, PostDto, PostType } from "@nicestack/common";
import { ObjectType, SocketMsgType, MessageDto, PostDto, PostType } from "@nicestack/common";
@Injectable()
export class RealtimeServer extends BaseWebSocketServer implements OnModuleInit {
onModuleInit() {
@ -11,12 +11,10 @@ export class RealtimeServer extends BaseWebSocketServer implements OnModuleInit
const receiverIds = (data as Partial<MessageDto>).receivers.map(receiver => receiver.id)
this.sendToUsers(receiverIds, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.MESSAGE } })
}
if (type === ObjectType.POST) {
const post = data as Partial<PostDto>
if (post.type === PostType.TROUBLE_INSTRUCTION || post.type === PostType.TROUBLE_PROGRESS) {
this.sendToRoom(post.referenceId, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.POST } })
}
}
})

View File

@ -10,20 +10,18 @@ import {
Staff,
TaxonomySlug,
Term,
TroubleType,
} from '@nicestack/common';
import * as argon2 from 'argon2';
import EventBus from '@server/utils/event-bus';
import {
calculateTroubleAttributes,
capitalizeFirstLetter,
determineState,
DevDataCounts,
getCounts,
getRandomImageLinks
} from './utils';
import { StaffService } from '@server/models/staff/staff.service';
import { uuidv4 } from 'lib0/random';
@Injectable()
export class GenDevService {
private readonly logger = new Logger(GenDevService.name);
@ -53,7 +51,7 @@ export class GenDevService {
await this.generateDepartments(3, 6);
await this.generateTerms(2, 6);
await this.generateStaffs(4);
} catch (err) {
this.logger.error(err);
}
@ -94,7 +92,7 @@ export class GenDevService {
}
private async generateSubDepartments(
parentId: string,
parentId: string | null,
currentDepth: number,
maxDepth: number,
count: number,
@ -103,7 +101,7 @@ export class GenDevService {
if (currentDepth > maxDepth) return;
for (let i = 0; i < count; i++) {
const deptName = `${parentId?.slice(0, 4) || '根'}公司${currentDepth}-${i}`;
const deptName = `${parentId?.slice(0, 6) || '根'}公司${currentDepth}-${i}`;
const newDept = await this.createDepartment(
deptName,
parentId,
@ -164,10 +162,11 @@ export class GenDevService {
this.deptStaffRecord[dept.id] = [];
}
for (let i = 0; i < countPerDept; i++) {
const username = `${dept.name}-S${staffsGenerated.toString().padStart(4, '0')}`;
const staff = await this.staffService.create({
data: {
showname: `${dept.name}-user${i}`,
username: `${dept.name}-user${i}`,
showname: username,
username: username,
deptId: dept.id,
domainId: domain.id
}
@ -184,10 +183,10 @@ export class GenDevService {
}
}
}
private async createDepartment(
name: string,
parentId?: string,
parentId?: string | null,
currentDepth: number = 1,
) {
const department = await this.departmentService.create({
@ -200,7 +199,7 @@ export class GenDevService {
return department;
}
private async createTerms(
domain: Department,
domain: Department | null,
taxonomySlug: TaxonomySlug,
depth: number,
nodesPerLevel: number,
@ -219,7 +218,7 @@ export class GenDevService {
const newTerm = await this.termService.create({
data: {
name,
taxonomyId: taxonomy.id,
taxonomyId: taxonomy!.id,
domainId: domain?.id,
parentId,
}

View File

@ -1,4 +1,4 @@
import { db, getRandomElement, getRandomIntInRange, getRandomTimeInterval, ObjectType, TroubleType } from '@nicestack/common';
import { db, getRandomElement, getRandomIntInRange, getRandomTimeInterval, } from '@nicestack/common';
import dayjs from 'dayjs';
export interface DevDataCounts {
deptCount: number;
@ -31,74 +31,4 @@ export function getRandomImageLinks(count: number = 5): string[] {
return imageLinks;
}
export function calculateTroubleAttributes(type: TroubleType) {
const probability = type === TroubleType.RISK ? getRandomIntInRange(1, 10) : getRandomIntInRange(25, 100);
const severity = type === TroubleType.RISK ? getRandomIntInRange(1, 10) : getRandomIntInRange(25, 100);
const impact = type === TroubleType.TROUBLE ? getRandomIntInRange(25, 100) : null;
const cost = type === TroubleType.TROUBLE ? getRandomIntInRange(25, 100) : null;
const deadline = type !== TroubleType.RISK ? getRandomTimeInterval(2024).endDate : null;
let level;
if (type === TroubleType.TROUBLE) {
level = getTroubleLevel(probability, severity, impact, cost, deadline);
} else if (type === TroubleType.RISK) {
level = getRiskLevel(probability, severity);
} else {
level = getRandomIntInRange(1, 4);
}
return { probability, severity, impact, cost, deadline, level };
}
export function determineState(type: TroubleType): number {
if (type === TroubleType.TROUBLE) {
return getRandomElement([0, 1, 2, 3]);
} else {
return getRandomElement([0, 4, 5]);
}
}
export function getTroubleLevel(
probability: number,
severity: number,
impact: number,
cost: number,
deadline: string | Date
) {
const deadlineDays = dayjs().diff(dayjs(deadline), "day");
let deadlineScore = 25;
if (deadlineDays > 365) {
deadlineScore = 100;
} else if (deadlineDays > 90) {
deadlineScore = 75;
} else if (deadlineDays > 30) {
deadlineScore = 50;
}
let total =
0.257 * probability +
0.325 * severity +
0.269 * impact +
0.084 * deadlineScore +
0.065 * cost;
if (total > 90) {
return 4;
} else if (total > 60) {
return 3;
} else if (total > 30) {
return 2;
} else if (probability * severity * impact * cost !== 1) {
return 1;
} else {
return 0;
}
}
export function getRiskLevel(probability: number, severity: number) {
if (probability * severity > 70) {
return 4;
} else if (probability * severity > 42) {
return 3;
} else if (probability * severity > 21) {
return 2;
}
return 1;
}

View File

@ -14,6 +14,9 @@ import { VisitModule } from '@server/models/visit/visit.module';
import { WebSocketModule } from '@server/socket/websocket.module';
import { RoleMapModule } from '@server/models/rbac/rbac.module';
import { TransformModule } from '@server/models/transform/transform.module';
import { CourseModule } from '@server/models/course/course.module';
import { LectureModule } from '@server/models/lecture/lecture.module';
import { SectionModule } from '@server/models/section/section.module';
@Module({
imports: [
AuthModule,
@ -28,6 +31,9 @@ import { TransformModule } from '@server/models/transform/transform.module';
AppConfigModule,
PostModule,
VisitModule,
CourseModule,
LectureModule,
SectionModule,
WebSocketModule
],
controllers: [],

View File

@ -14,6 +14,9 @@ import { VisitRouter } from '@server/models/visit/visit.router';
import { RoleMapRouter } from '@server/models/rbac/rolemap.router';
import { TransformRouter } from '@server/models/transform/transform.router';
import { RoleRouter } from '@server/models/rbac/role.router';
import { CourseRouter } from '@server/models/course/course.router';
import { LectureRouter } from '@server/models/lecture/lecture.router';
import { SectionRouter } from '@server/models/section/section.router';
@Injectable()
export class TrpcRouter {
logger = new Logger(TrpcRouter.name)
@ -31,6 +34,9 @@ export class TrpcRouter {
private readonly app_config: AppConfigRouter,
private readonly message: MessageRouter,
private readonly visitor: VisitRouter,
private readonly course: CourseRouter,
private readonly lecture: LectureRouter,
private readonly section: SectionRouter
// private readonly websocketService: WebSocketService
) { }
appRouter = this.trpc.router({
@ -45,7 +51,10 @@ export class TrpcRouter {
rolemap: this.rolemap.router,
message: this.message.router,
app_config: this.app_config.router,
visitor: this.visitor.router
visitor: this.visitor.router,
course: this.course.router,
lecture: this.lecture.router,
section: this.section.router
});
wss: WebSocketServer = undefined

View File

@ -1,5 +1,5 @@
import mitt from 'mitt';
import { ObjectType, ChangedRows, UserProfile, MessageDto } from '@nicestack/common';
import { ObjectType, UserProfile, MessageDto } from '@nicestack/common';
export enum CrudOperation {
CREATED,
UPDATED,

View File

@ -12,7 +12,7 @@
VITE_APP_VERSION: "$VITE_APP_VERSION",
};
</script>
<title>两道防线管理后台</title>
<title>烽火慕课</title>
</head>
<body>

View File

@ -25,9 +25,17 @@
"@ag-grid-enterprise/status-bar": "~32.3.2",
"@ant-design/icons": "^5.4.0",
"@floating-ui/react": "^0.26.25",
"@nicestack/common": "workspace:^",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^3.9.1",
"@nicestack/client": "workspace:^",
"@nicestack/common": "workspace:^",
"@nicestack/iconer": "workspace:^",
"@tanstack/query-async-storage-persister": "^5.51.9",
"@tanstack/react-query": "^5.51.21",
"@tanstack/react-query-persist-client": "^5.51.9",
"@trpc/client": "11.0.0-rc.456",
"@trpc/react-query": "11.0.0-rc.456",
"@trpc/server": "11.0.0-rc.456",
"ag-grid-community": "~32.3.2",
"ag-grid-enterprise": "~32.3.2",
"ag-grid-react": "~32.3.2",
@ -35,22 +43,19 @@
"axios": "^1.7.2",
"browser-image-compression": "^2.0.2",
"dayjs": "^1.11.12",
"framer-motion": "^11.11.9",
"framer-motion": "^11.15.0",
"idb-keyval": "^6.2.1",
"mitt": "^3.0.1",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.4.1",
"react-resizable": "^3.0.5",
"react-router-dom": "^6.24.1",
"superjson": "^2.2.1",
"@tanstack/query-async-storage-persister": "^5.51.9",
"@tanstack/react-query": "^5.51.21",
"@tanstack/react-query-persist-client": "^5.51.9",
"@trpc/client": "11.0.0-rc.456",
"@trpc/react-query": "11.0.0-rc.456",
"@trpc/server": "11.0.0-rc.456",
"zod": "^3.23.8",
"yjs": "^13.6.20",
"mitt": "^3.0.1"
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^9.9.0",

View File

@ -10,30 +10,34 @@ import locale from "antd/locale/zh_CN";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import { AuthProvider } from './providers/auth-provider';
import { Toaster } from 'react-hot-toast';
dayjs.locale("zh-cn");
function App() {
return (
<AuthProvider>
<QueryProvider>
<ConfigProvider
locale={locale}
theme={{
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: "#2e75b6",
},
components: {},
}}>
<ThemeProvider>
<AntdApp>
<RouterProvider router={router}></RouterProvider>
</AntdApp>
</ThemeProvider>
</ConfigProvider>
</QueryProvider>
</AuthProvider>
<>
<AuthProvider>
<QueryProvider>
<ConfigProvider
locale={locale}
theme={{
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: "#2e75b6",
},
components: {},
}}>
<ThemeProvider>
<AntdApp>
<RouterProvider router={router}></RouterProvider>
</AntdApp>
</ThemeProvider>
</ConfigProvider>
</QueryProvider>
</AuthProvider>
<Toaster />
</>
);
}

View File

@ -13,10 +13,11 @@ import {
} from "antd";
import { useAppConfig } from "@nicestack/client";
import { useAuth } from "@web/src/providers/auth-provider";
import { MainLayoutContext } from "../../layout";
import FixedHeader from "@web/src/components/layout/fix-header";
import { useForm } from "antd/es/form/Form";
import { api } from "@nicestack/client"
import { MainLayoutContext } from "../layout";
export default function BaseSettingPage() {
const { update, baseSetting } = useAppConfig();

View File

@ -0,0 +1,5 @@
import CourseEditor from "@web/src/components/models/course/manage/CourseEditor";
export function CourseEditorPage() {
return <CourseEditor></CourseEditor>
}

View File

@ -0,0 +1,34 @@
import { CourseCard } from "@web/src/components/models/course/course-card"
import { CourseDetail } from "@web/src/components/models/course/course-detail"
import { CourseSyllabus } from "@web/src/components/models/course/course-syllabus"
export const CoursePage = () => {
// 假设这些数据从API获取
const course: any = {
/* course data */
}
const sections: any = [
/* sections data */
]
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 左侧课程详情 */}
<div className="lg:col-span-2">
<CourseDetail course={course} />
</div>
{/* 右侧课程大纲 */}
<div className="space-y-4">
<CourseCard course={course} />
<CourseSyllabus
sections={sections}
onLectureClick={(lectureId) => {
console.log('Clicked lecture:', lectureId)
}}
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,198 @@
import { motion } from "framer-motion";
import { useState } from "react";
import { CourseDto } from "@nicestack/common";
import { EmptyStateIllustration } from "@web/src/components/presentation/EmptyStateIllustration";
import { useNavigate } from "react-router-dom";
interface CourseCardProps {
course: CourseDto;
type: "created" | "enrolled";
}
const CourseCard = ({ course, type }: CourseCardProps) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ scale: 1.02 }}
className="group relative overflow-hidden rounded-2xl bg-white p-6 shadow-sm transition-all hover:shadow-md"
>
{/* Course Thumbnail */}
<div className="relative mb-4 aspect-video w-full overflow-hidden rounded-xl">
<motion.img
src={course.thumbnail || "/default-course-thumb.jpg"}
alt={course.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
<div className="absolute bottom-2 left-2">
<span className="rounded-full bg-white/30 px-3 py-1 text-sm text-white backdrop-blur-sm">
{course.level}
</span>
</div>
</div>
{/* Course Info */}
<div className="space-y-2">
<h3 className="text-xl font-medium text-gray-700">
{course.title}
</h3>
{course.subTitle && (
<p className="text-sm text-gray-500">
{course.subTitle}
</p>
)}
</div>
{/* Course Stats */}
<div className="mt-4 flex items-center justify-between text-sm">
<div className="flex items-center space-x-2 text-gray-500">
<span>{course.totalLectures} lectures</span>
<span></span>
<span>{course.totalDuration} mins</span>
</div>
<div className="flex items-center space-x-1">
<span className="text-amber-400"></span>
<span className="text-gray-600">
{course.averageRating.toFixed(1)}
</span>
</div>
</div>
{/* Progress Bar (Only for enrolled courses) */}
{type === "enrolled" && course.enrollments[0] && (
<div className="mt-4">
<div className="relative h-2 w-full overflow-hidden rounded-full bg-gray-100">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${course.enrollments[0].completionRate}%` }}
className="absolute left-0 top-0 h-full rounded-full bg-indigo-400"
transition={{ duration: 1, ease: "easeOut" }}
/>
</div>
<p className="mt-1 text-right text-xs text-gray-400">
{course.enrollments[0].completionRate}% Complete
</p>
</div>
)}
</motion.div>
);
};
export default function CoursesPage() {
const [activeTab, setActiveTab] = useState<"enrolled" | "created">("enrolled");
const [courses, setCourses] = useState<CourseDto[]>([]);
const navigate = useNavigate()
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
duration: 0.3
},
},
};
return (
<div className="min-h-screen bg-slate-50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-semibold text-slate-800">
</h1>
<div className="mt-4">
<nav className="flex space-x-4">
{["enrolled", "created"].map((tab) => (
<motion.button
key={tab}
onClick={() => setActiveTab(tab as "enrolled" | "created")}
className={`relative rounded-lg px-6 py-2.5 text-sm font-medium ${activeTab === tab
? "bg-blue-500 text-white shadow-sm shadow-sky-100"
: "bg-white text-slate-600 hover:bg-white hover:text-blue-500 hover:shadow-sm"
}`}
whileHover={{ y: -2 }}
whileTap={{ y: 0 }}
transition={{ duration: 0.2 }}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)} Courses
</motion.button>
))}
</nav>
</div>
</div>
{activeTab === "created" && (
<motion.button
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
whileHover={{ y: -2 }}
whileTap={{ y: 0 }}
onClick={() => {
navigate("/course/manage")
}}
transition={{ duration: 0.2 }}
className="flex items-center gap-2 rounded-lg bg-blue-500 px-6 py-3 text-sm font-medium text-white shadow-sm transition-all duration-200 hover:bg-blue-600 hover:shadow-md"
>
<span className="relative h-5 w-5">
<motion.svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="h-5 w-5"
whileHover={{ rotate: 90 }}
transition={{ duration: 0.2 }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</motion.svg>
</span>
</motion.button>
)}
</div>
{/* Course Grid */}
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{courses.map((course) => (
<CourseCard
key={course.id}
course={course}
type={activeTab}
/>
))}
</motion.div>
{/* Empty State */}
{courses.length === 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="flex flex-col items-center justify-center rounded-xl bg-white p-8 text-center shadow-sm"
>
<EmptyStateIllustration />
<h3 className="mb-2 text-xl font-medium text-slate-800">
No courses found
</h3>
<p className="text-slate-500">
{activeTab === "enrolled"
? "You haven't enrolled in any courses yet."
: "You haven't created any courses yet."}
</p>
</motion.div>
)}
</div>
</div>
);
}

View File

@ -1,3 +0,0 @@
export default function MainPage() {
return <div>main</div>
}

View File

@ -0,0 +1,39 @@
import { useState, useEffect, useRef, ReactNode } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { TopNavBar } from '@web/src/components/layout/main/top-nav-bar';
import { navItems, notificationItems } from '@web/src/components/layout/main/nav-data';
import { Sidebar } from '@web/src/components/layout/main/side-bar';
export function MainLayout({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [notifications, setNotifications] = useState(3);
const [recentSearches] = useState([
'React Fundamentals',
'TypeScript Advanced',
'Tailwind CSS Projects',
]);
return (
<div className="min-h-screen bg-gray-50">
<TopNavBar
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
notifications={notifications}
notificationItems={notificationItems}
recentSearches={recentSearches}
/>
<AnimatePresence mode="wait">
{sidebarOpen && <Sidebar navItems={navItems} />}
</AnimatePresence>
<main
className={`pt-16 min-h-screen transition-all duration-300 ${sidebarOpen ? 'ml-64' : 'ml-0'
}`}
>
{children}
</main>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { NavItem } from '@nicestack/client';
import {
HomeIcon,
BookOpenIcon,
UserGroupIcon,
Cog6ToothIcon,
BellIcon,
HeartIcon,
AcademicCapIcon
} from '@heroicons/react/24/outline';
export const navItems: NavItem[] = [
{ icon: <HomeIcon className="w-6 h-6" />, label: '探索', path: '/' },
{ icon: <BookOpenIcon className="w-6 h-6" />, label: '我的课程', path: '/courses' },
{ icon: <UserGroupIcon className="w-6 h-6" />, label: '学习社区', path: '/community' },
{ icon: <Cog6ToothIcon className="w-6 h-6" />, label: '应用设置', path: '/settings' },
];
export const notificationItems = [
{
icon: <BellIcon className="w-6 h-6 text-blue-500" />,
title: "New Course Available",
description: "Advanced TypeScript Programming is now available",
time: "2 hours ago",
isUnread: true,
},
{
icon: <HeartIcon className="w-6 h-6 text-red-500" />,
title: "Course Recommendation",
description: "Based on your interests: React Native Development",
time: "1 day ago",
isUnread: true,
},
{
icon: <AcademicCapIcon className="w-6 h-6 text-green-500" />,
title: "Certificate Ready",
description: "Your React Fundamentals certificate is ready to download",
time: "2 days ago",
isUnread: true,
},
];

View File

@ -0,0 +1,40 @@
import { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { NotificationsPanel } from './notifications-panel';
import { BellIcon } from '@heroicons/react/24/outline';
import { useClickOutside } from '@web/src/hooks/useClickOutside';
interface NotificationsDropdownProps {
notifications: number;
notificationItems: Array<any>;
}
export function NotificationsDropdown({ notifications, notificationItems }: NotificationsDropdownProps) {
const [showNotifications, setShowNotifications] = useState(false);
const notificationRef = useRef<HTMLDivElement>(null);
useClickOutside(notificationRef, () => setShowNotifications(false));
return (
<div ref={notificationRef} className="relative">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
onClick={() => setShowNotifications(!showNotifications)}
>
<BellIcon className='w-6 h-6' ></BellIcon>
{notifications > 0 && (
<span className="absolute top-0 right-0 w-5 h-5 bg-red-500 text-white rounded-full text-xs flex items-center justify-center">
{notifications}
</span>
)}
</motion.button>
<AnimatePresence>
{showNotifications && (
<NotificationsPanel notificationItems={notificationItems} />
)}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,64 @@
import { ClockIcon } from '@heroicons/react/24/outline';
import { motion } from 'framer-motion';
interface NotificationsPanelProps {
notificationItems: Array<{
icon: React.ReactNode;
title: string;
description: string;
time: string;
isUnread: boolean;
}>;
}
export function NotificationsPanel({ notificationItems }: NotificationsPanelProps) {
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden"
>
<div className="p-4 border-b border-gray-100">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
<span className="text-sm text-blue-600 hover:text-blue-700 cursor-pointer">
Mark all as read
</span>
</div>
</div>
<div className="max-h-[400px] overflow-y-auto overflow-x-hidden">
{notificationItems.map((item, index) => (
<motion.div
key={index}
whileHover={{ x: 4 }}
className={`p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors ${item.isUnread ? 'bg-blue-50/50' : ''
}`}
>
<div className="flex gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
{item.icon}
</div>
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-900">{item.title}</h4>
<p className="text-sm text-gray-600 mt-1">{item.description}</p>
<div className="flex items-center gap-1 mt-2 text-xs text-gray-500">
<ClockIcon className='h-4 w-4'></ClockIcon>
<span>{item.time}</span>
</div>
</div>
</div>
</motion.div>
))}
</div>
<div className="p-4 border-t border-gray-100 bg-gray-50">
<button className="w-full text-sm text-center text-blue-600 hover:text-blue-700">
View all notifications
</button>
</div>
</motion.div>
);
}

View File

@ -0,0 +1,55 @@
import { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SearchDropdown } from './search-dropdown';
import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { useClickOutside } from '@web/src/hooks/useClickOutside';
interface SearchBarProps {
recentSearches: string[];
}
export function SearchBar({ recentSearches }: SearchBarProps) {
const [searchFocused, setSearchFocused] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const searchRef = useRef<HTMLDivElement>(null);
useClickOutside(searchRef, () => setSearchFocused(false))
return (
<div ref={searchRef} className="relative max-w-xl w-full px-4">
<div className={`
relative flex items-center w-full h-10 rounded-full
transition-all duration-300 ease-in-out
${searchFocused
? 'bg-white shadow-md ring-2 ring-blue-500'
: 'bg-gray-100 hover:bg-gray-200'
}
`}>
<MagnifyingGlassIcon className="h-5 w-5 ml-3 text-gray-500" />
<input
type="text"
placeholder="Search for courses, topics, or instructors..."
className="w-full h-full bg-transparent px-3 outline-none text-sm"
onFocus={() => setSearchFocused(true)}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="p-1.5 mr-2 rounded-full hover:bg-gray-200"
onClick={() => setSearchQuery('')}
>
<XMarkIcon className="h-4 w-4 text-gray-500" />
</motion.button>
)}
</div>
<SearchDropdown
searchFocused={searchFocused}
searchQuery={searchQuery}
recentSearches={recentSearches}
setSearchQuery={setSearchQuery}
/>
</div>
);
}

View File

@ -0,0 +1,58 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { motion } from 'framer-motion';
interface SearchDropdownProps {
searchFocused: boolean;
searchQuery: string;
recentSearches: string[];
setSearchQuery: (query: string) => void;
}
export function SearchDropdown({
searchFocused,
searchQuery,
recentSearches,
setSearchQuery
}: SearchDropdownProps) {
if (!searchFocused) return null;
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="absolute top-12 left-4 right-4 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden"
>
<div className="p-3">
<h3 className="text-xs font-medium text-gray-500 mb-2">Recent Searches</h3>
<div className="space-y-1">
{recentSearches.map((search, index) => (
<motion.button
key={index}
whileHover={{ x: 4 }}
className="flex items-center gap-2 w-full p-2 rounded-lg hover:bg-gray-100 text-left"
onClick={() => setSearchQuery(search)}
>
<MagnifyingGlassIcon className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-700">{search}</span>
</motion.button>
))}
</div>
</div>
{searchQuery && (
<div className="border-t border-gray-100 p-3">
<motion.button
whileHover={{ x: 4 }}
className="flex items-center gap-2 w-full p-2 rounded-lg hover:bg-gray-100"
>
<MagnifyingGlassIcon className="h-4 w-4 text-blue-500" />
<span className="text-sm text-blue-500">
Search for "{searchQuery}"
</span>
</motion.button>
</div>
)}
</motion.div>
);
}

View File

@ -0,0 +1,44 @@
import { motion } from 'framer-motion';
import { useNavigate, useLocation } from 'react-router-dom';
import { NavItem } from '@nicestack/client';
interface SidebarProps {
navItems: Array<NavItem>;
}
export function Sidebar({ navItems }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
return (
<motion.aside
initial={{ x: -300, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -300, opacity: 0 }}
transition={{ type: "spring", bounce: 0.1, duration: 0.5 }}
className="fixed left-0 top-16 bottom-0 w-64 bg-white border-r border-gray-200 z-40"
>
<div className="p-4 space-y-2">
{navItems.map((item, index) => {
const isActive = location.pathname === item.path;
return (
<motion.button
key={index}
whileHover={{ x: 5 }}
onClick={() => {
navigate(item.path)
}}
className={`flex items-center gap-3 w-full p-3 rounded-lg transition-colors
${isActive
? 'bg-blue-50 text-blue-600'
: 'text-gray-700 hover:bg-blue-50 hover:text-blue-600'
}`}
>
{item.icon}
<span className="font-medium">{item.label}</span>
</motion.button>
);
})}
</div>
</motion.aside>
);
}

View File

@ -0,0 +1,50 @@
import { useState, useRef } from 'react';
import { NotificationsDropdown } from './notifications-dropdown';
import { SearchBar } from './search-bar';
import { UserMenuDropdown } from './usermenu-dropdown';
import { Bars3Icon } from '@heroicons/react/24/outline';
interface TopNavBarProps {
sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void;
notifications: number;
notificationItems: Array<any>;
recentSearches: string[];
}
export function TopNavBar({
sidebarOpen,
setSidebarOpen,
notifications,
notificationItems,
recentSearches
}: TopNavBarProps) {
return (
<nav className="fixed top-0 left-0 right-0 h-16 bg-white shadow-sm z-50">
<div className="flex items-center justify-between h-full px-4">
<div className="flex items-center gap-4">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<Bars3Icon />
</button>
<h1 className="text-xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
LearnHub
</h1>
</div>
<SearchBar recentSearches={recentSearches} />
<div className="flex items-center gap-4">
<NotificationsDropdown
notifications={notifications}
notificationItems={notificationItems}
/>
<UserMenuDropdown />
</div>
</div>
</nav>
);
}

View File

@ -0,0 +1,68 @@
import { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeftStartOnRectangleIcon, Cog6ToothIcon, QuestionMarkCircleIcon, UserCircleIcon } from '@heroicons/react/24/outline';
import { useAuth } from '@web/src/providers/auth-provider';
import { Avatar } from '../../presentation/user/Avatar';
import { useClickOutside } from '@web/src/hooks/useClickOutside';
export function UserMenuDropdown() {
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { user, logout } = useAuth()
useClickOutside(menuRef, () => setShowMenu(false));
const menuItems = [
{ icon: <UserCircleIcon className='w-5 h-5'></UserCircleIcon>, label: '个人信息', action: () => { } },
{ icon: <Cog6ToothIcon className='w-5 h-5'></Cog6ToothIcon>, label: '设置', action: () => { } },
{ icon: <QuestionMarkCircleIcon className='w-5 h-5'></QuestionMarkCircleIcon>, label: '帮助', action: () => { } },
{ icon: <ArrowLeftStartOnRectangleIcon className='w-5 h-5' />, label: '注销', action: () => { logout() } },
];
return (
<div ref={menuRef} className="relative">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setShowMenu(!showMenu)}
className="w-10 h-10" // 移除了边框相关的类
>
<Avatar
src={user?.avatar}
name={user?.showname || user?.username}
size={40}
className="ring-2 ring-gray-200 hover:ring-blue-500 transition-colors" // 使用 ring 替代 border
/>
</motion.button>
<AnimatePresence>
{showMenu && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden"
>
<div className="p-3 border-b border-gray-100">
<h4 className="text-sm font-medium text-gray-900">{user?.showname}</h4>
<p className="text-xs text-gray-500">{user?.username}</p>
</div>
<div className="p-2">
{menuItems.map((item, index) => (
<motion.button
key={index}
whileHover={{ x: 4 }}
onClick={item.action}
className="flex items-center gap-2 w-full p-2 rounded-lg hover:bg-gray-100 text-gray-700 text-sm"
>
{item.icon}
<span>{item.label}</span>
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -20,12 +20,7 @@ export default function SidebarContent() {
// icon: <Icon name={"home"}></Icon>,
// link: "/",
// },
{
key: "trouble",
label: "问题列表",
icon: <Icon name={"list"}></Icon>,
link: "/troubles",
},
hasSomePermissions(
RolePerms.MANAGE_ANY_DEPT,
RolePerms.MANAGE_ANY_STAFF,

View File

@ -0,0 +1,30 @@
import { CourseDto } from '@nicestack/common';
import { Card } from '@web/src/components/presentation/container/Card';
import { CourseHeader } from './CourseHeader';
import { CourseStats } from './CourseStats';
interface CourseCardProps {
course: CourseDto;
onClick?: () => void;
}
export const CourseCard = ({ course, onClick }: CourseCardProps) => {
return (
<Card onClick={onClick} className="w-full max-w-sm">
<CourseHeader
title={course.title}
subTitle={course.subTitle}
thumbnail={course.thumbnail}
level={course.level}
numberOfStudents={course.numberOfStudents}
publishedAt={course.publishedAt}
/>
<CourseStats
averageRating={course.averageRating}
numberOfReviews={course.numberOfReviews}
completionRate={course.completionRate}
totalDuration={course.totalDuration}
/>
</Card>
);
};

View File

@ -0,0 +1,59 @@
import { CalendarIcon, UserGroupIcon, AcademicCapIcon } from '@heroicons/react/24/outline';
interface CourseHeaderProps {
title: string;
subTitle?: string;
thumbnail?: string;
level?: string;
numberOfStudents?: number;
publishedAt?: Date;
}
export const CourseHeader = ({
title,
subTitle,
thumbnail,
level,
numberOfStudents,
publishedAt,
}: CourseHeaderProps) => {
return (
<div className="relative">
{thumbnail && (
<div className="relative h-48 w-full">
<img
src={thumbnail}
alt={title}
className="object-cover"
/>
</div>
)}
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{title}</h3>
{subTitle && (
<p className="mt-2 text-gray-600 dark:text-gray-300">{subTitle}</p>
)}
<div className="mt-4 flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
{level && (
<div className="flex items-center gap-1">
<AcademicCapIcon className="h-4 w-4" />
<span>{level}</span>
</div>
)}
{numberOfStudents !== undefined && (
<div className="flex items-center gap-1">
<UserGroupIcon className="h-4 w-4" />
<span>{numberOfStudents} students</span>
</div>
)}
{publishedAt && (
<div className="flex items-center gap-1">
<CalendarIcon className="h-4 w-4" />
<span>{publishedAt.toLocaleDateString()}</span>
</div>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,59 @@
import { StarIcon, ChartBarIcon, ClockIcon } from '@heroicons/react/24/solid';
interface CourseStatsProps {
averageRating?: number;
numberOfReviews?: number;
completionRate?: number;
totalDuration?: number;
}
export const CourseStats = ({
averageRating,
numberOfReviews,
completionRate,
totalDuration,
}: CourseStatsProps) => {
return (
<div className="grid grid-cols-2 gap-4 p-6 bg-gray-50 dark:bg-gray-900">
{averageRating !== undefined && (
<div className="flex items-center gap-2">
<StarIcon className="h-5 w-5 text-yellow-400" />
<div>
<div className="font-semibold text-gray-900 dark:text-white">
{averageRating.toFixed(1)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{numberOfReviews} reviews
</div>
</div>
</div>
)}
{completionRate !== undefined && (
<div className="flex items-center gap-2">
<ChartBarIcon className="h-5 w-5 text-green-500" />
<div>
<div className="font-semibold text-gray-900 dark:text-white">
{completionRate}%
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Completion
</div>
</div>
</div>
)}
{totalDuration !== undefined && (
<div className="flex items-center gap-2">
<ClockIcon className="h-5 w-5 text-blue-500" />
<div>
<div className="font-semibold text-gray-900 dark:text-white">
{Math.floor(totalDuration / 60)}h {totalDuration % 60}m
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Duration
</div>
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,75 @@
import { CheckCircleIcon } from '@heroicons/react/24/outline'
import { Course } from "@nicestack/common"
interface CourseDetailProps {
course: Course
}
export const CourseDetail: React.FC<CourseDetailProps> = ({ course }) => {
return (
<div className="space-y-8">
{/* 课程标题区域 */}
<div className="space-y-4">
<h1 className="text-3xl font-bold">{course.title}</h1>
{course.subTitle && (
<p className="text-xl text-gray-600">{course.subTitle}</p>
)}
</div>
{/* 课程描述 */}
<div className="prose max-w-none">
<p>{course.description}</p>
</div>
{/* 学习目标 */}
<div>
<h2 className="text-xl font-semibold mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{course.objectives.map((objective, index) => (
<div key={index} className="flex items-start gap-2">
<CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-1" />
<span>{objective}</span>
</div>
))}
</div>
</div>
{/* 适合人群 */}
<div>
<h2 className="text-xl font-semibold mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{course.audiences.map((audience, index) => (
<div key={index} className="flex items-start gap-2">
<CheckCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0 mt-1" />
<span>{audience}</span>
</div>
))}
</div>
</div>
{/* 课程要求 */}
<div>
<h2 className="text-xl font-semibold mb-4"></h2>
<ul className="list-disc list-inside space-y-2 text-gray-700">
{course.requirements.map((requirement, index) => (
<li key={index}>{requirement}</li>
))}
</ul>
</div>
{/* 可获得技能 */}
<div>
<h2 className="text-xl font-semibold mb-4"></h2>
<div className="flex flex-wrap gap-2">
{course.skills.map((skill, index) => (
<span
key={index}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
>
{skill}
</span>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,29 @@
import { CheckOutlined } from '@ant-design/icons';
import React from 'react';
interface CourseObjectivesProps {
objectives: string[];
title?: string;
}
const CourseObjectives: React.FC<CourseObjectivesProps> = ({
objectives,
title = "您将会学到"
}) => {
return (
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-xl font-bold mb-4">{title}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{objectives.map((objective, index) => (
<div
key={index}
className="flex items-start space-x-3"
>
<CheckOutlined></CheckOutlined>
<span className="text-gray-700">{objective}</span>
</div>
))}
</div>
</div>
);
};
export default CourseObjectives;

View File

@ -0,0 +1,78 @@
import { ChevronDownIcon, ClockIcon, PlayCircleIcon } from '@heroicons/react/24/outline'
import { useState } from 'react'
import { Section, SectionDto } from "@nicestack/common"
interface CourseSyllabusProps {
sections: SectionDto[]
onLectureClick?: (lectureId: string) => void
}
export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
sections,
onLectureClick
}) => {
const [expandedSections, setExpandedSections] = useState<string[]>([])
const toggleSection = (sectionId: string) => {
setExpandedSections(prev =>
prev.includes(sectionId)
? prev.filter(id => id !== sectionId)
: [...prev, sectionId]
)
}
return (
<div className="space-y-4">
{sections.map((section) => (
<div key={section.id} className="border rounded-lg">
{/* 章节标题 */}
<button
className="w-full flex items-center justify-between p-4 hover:bg-gray-50"
onClick={() => toggleSection(section.id)}
>
<div className="flex items-center gap-4">
<span className="text-lg font-medium">
{Math.floor(section.order)}
</span>
<div>
<h3 className="text-left font-medium">{section.title}</h3>
<p className="text-sm text-gray-500">
{section.totalLectures} · {Math.floor(section.totalDuration / 60)}
</p>
</div>
</div>
<ChevronDownIcon
className={`w-5 h-5 transition-transform duration-200 ${expandedSections.includes(section.id) ? 'rotate-180' : ''
}`}
/>
</button>
{/* 课时列表 */}
{expandedSections.includes(section.id) && (
<div className="border-t">
{section.lectures.map((lecture) => (
<button
key={lecture.id}
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left"
onClick={() => onLectureClick?.(lecture.id)}
>
<PlayCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0" />
<div className="flex-grow">
<h4 className="font-medium">{lecture.title}</h4>
{lecture.description && (
<p className="text-sm text-gray-500 mt-1">{lecture.description}</p>
)}
</div>
<div className="flex items-center gap-1 text-sm text-gray-500">
<ClockIcon className="w-4 h-4" />
<span>{lecture.duration}</span>
</div>
</button>
))}
</div>
)}
</div>
))}
</div>
)
}

View File

@ -0,0 +1,42 @@
// CourseList.tsx
import { motion } from "framer-motion";
import { Course, CourseDto } from "@nicestack/common";
import { EmptyState } from "@web/src/components/presentation/space/Empty";
interface CourseListProps {
courses: CourseDto[];
activeTab: "enrolled" | "created";
}
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
duration: 0.3
},
},
};
export const CourseList = ({
courses,
renderItem,
emptyComponent: EmptyComponent,
}: CourseListProps & {
renderItem?: (course: CourseDto) => React.ReactNode;
emptyComponent?: React.ReactNode;
}) => {
if (courses.length === 0) {
return EmptyComponent || (
<EmptyState />
);
}
return (
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{courses.map((course) => renderItem(course))}
</motion.div>
);
};

View File

@ -0,0 +1,35 @@
import { SubmitHandler, useFormContext } from 'react-hook-form';
import { CourseFormData, useCourseForm } from './CourseEditorContext';
import { CourseLevel } from '@nicestack/common';
import { FormInput } from '@web/src/components/presentation/form/FormInput';
import { FormSelect } from '@web/src/components/presentation/form/FormSelect';
import { FormArrayField } from '@web/src/components/presentation/form/FormArrayField';
export function CourseBasicForm() {
const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
return (
<form className="max-w-2xl mx-auto space-y-6 p-6">
<FormInput maxLength={20} name="title" label="课程标题" placeholder="请输入课程标题" />
<FormInput maxLength={10} name="subTitle" label="课程副标题" placeholder="请输入课程副标题" />
<FormInput
name="description"
label="课程描述"
type="textarea"
placeholder="请输入课程描述"
/>
<FormSelect name='level' label='难度等级' options={[
{ label: '入门', value: CourseLevel.BEGINNER },
{
label: '中级', value: CourseLevel.INTERMEDIATE
},
{
label: '高级', value: CourseLevel.ADVANCED
}
]}></FormSelect>
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
</form>
);
}

View File

@ -0,0 +1,12 @@
import { CourseBasicForm } from "./CourseBasicForm";
import { CourseFormProvider } from "./CourseEditorContext";
import CourseEditorLayout from "./CourseEditorLayout";
export default function CourseEditor() {
return <CourseFormProvider>
<CourseEditorLayout>
<CourseBasicForm></CourseBasicForm>
</CourseEditorLayout>
</CourseFormProvider>
}

View File

@ -0,0 +1,71 @@
import { createContext, useContext, ReactNode } from 'react';
import { useForm, FormProvider, SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { CourseLevel, CourseStatus } from '@nicestack/common';
import { useCourse } from '@nicestack/client';
// 定义课程表单验证 Schema
const courseSchema = z.object({
title: z.string().min(1, '课程标题不能为空'),
subTitle: z.string().optional(),
description: z.string().optional(),
thumbnail: z.string().url().optional(),
level: z.nativeEnum(CourseLevel),
requirements: z.array(z.string()).optional(),
objectives: z.array(z.string()).optional(),
skills: z.array(z.string()).optional(),
audiences: z.array(z.string()).optional(),
status: z.nativeEnum(CourseStatus),
});
export type CourseFormData = z.infer<typeof courseSchema>;
interface CourseEditorContextType {
onSubmit: SubmitHandler<CourseFormData>;
}
const CourseEditorContext = createContext<CourseEditorContextType | null>(null);
export function CourseFormProvider({ children }: { children: ReactNode }) {
const { create } = useCourse()
const methods = useForm<CourseFormData>({
resolver: zodResolver(courseSchema),
defaultValues: {
status: CourseStatus.DRAFT,
level: CourseLevel.BEGINNER,
requirements: [],
objectives: [],
skills: [],
audiences: [],
},
});
const onSubmit: SubmitHandler<CourseFormData> = async (data: CourseFormData) => {
try {
// TODO: 实现API调用
console.log('Form data:', data);
await create.mutateAsync({
data: {
...data
}
})
} catch (error) {
console.error('Error submitting form:', error);
}
};
return (
<CourseEditorContext.Provider value={{ onSubmit }}>
<FormProvider {...methods}>
{children}
</FormProvider>
</CourseEditorContext.Provider>
);
}
export const useCourseForm = () => {
const context = useContext(CourseEditorContext);
if (!context) {
throw new Error('useCourseForm must be used within CourseFormProvider');
}
return context;
};

View File

@ -0,0 +1,35 @@
import { ArrowLeftIcon, ClockIcon } from '@heroicons/react/24/outline';
import { SubmitHandler, useFormContext } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { CourseFormData, useCourseForm } from './CourseEditorContext';
export default function CourseEditorHeader() {
const navigate = useNavigate();
const { handleSubmit} = useFormContext<CourseFormData>()
const { onSubmit } = useCourseForm()
return (
<header className="fixed top-0 left-0 right-0 h-16 bg-white border-b border-gray-200 z-10">
<div className="h-full flex items-center justify-between px-3 md:px-4">
<div className="flex items-center space-x-3">
<button
onClick={() => navigate(-1)}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeftIcon className="w-5 h-5 text-gray-600" />
</button>
<div className="flex items-center space-x-2">
<h2 className="font-medium text-gray-900">UI设计入门课程</h2>
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-50 text-blue-600"></span>
<div className="hidden md:flex items-center text-gray-500 text-sm">
<ClockIcon className="w-4 h-4 mr-1" />
<span> 12:30:00</span>
</div>
</div>
</div>
<button onClick={handleSubmit(onSubmit)} className="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
</button>
</div>
</header>
);
}

View File

@ -0,0 +1,50 @@
import { ReactNode, useState } from "react";
import { useNavigate } from "react-router-dom";
import { DEFAULT_NAV_ITEMS } from "./navItems";
import CourseEditorHeader from "./CourseEditorHeader";
import { motion } from "framer-motion";
import { NavItem } from "@nicestack/client"
import CourseEditorSidebar from "./CourseEditorSidebar";
interface CourseEditorLayoutProps {
children: ReactNode;
}
export default function CourseEditorLayout({ children }: CourseEditorLayoutProps) {
const [isHovered, setIsHovered] = useState(false);
const [selectedSection, setSelectedSection] = useState<number>(0);
const [navItems, setNavItems] = useState<NavItem[]>(DEFAULT_NAV_ITEMS);
const navigate = useNavigate();
const handleNavigation = (item: NavItem, index: number) => {
setSelectedSection(index);
navigate(item.path);
};
return (
<div className="min-h-screen bg-gray-50">
<CourseEditorHeader />
<div className="flex pt-16">
<CourseEditorSidebar
isHovered={isHovered}
setIsHovered={setIsHovered}
navItems={navItems}
selectedSection={selectedSection}
onNavigate={handleNavigation}
/>
<motion.main
animate={{ marginLeft: isHovered ? "16rem" : "5rem" }}
transition={{ type: "spring", stiffness: 200, damping: 25, mass: 1 }}
className="flex-1 p-8"
>
<div className="max-w-6xl mx-auto bg-white rounded-xl shadow-lg">
<header className="p-6 border-b border-gray-100">
<h1 className="text-2xl font-bold text-gray-900">
{navItems[selectedSection]?.label}
</h1>
</header>
<div className="p-6">{children}</div>
</div>
</motion.main>
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
import { motion } from 'framer-motion';
import { NavItem } from "@nicestack/client"
interface CourseSidebarProps {
isHovered: boolean;
setIsHovered: (value: boolean) => void;
navItems: NavItem[];
selectedSection: number;
onNavigate: (item: NavItem, index: number) => void;
}
export default function CourseEditorSidebar({
isHovered,
setIsHovered,
navItems,
selectedSection,
onNavigate
}: CourseSidebarProps) {
return (
<motion.nav
initial={{ width: "5rem" }}
animate={{ width: isHovered ? "16rem" : "5rem" }}
transition={{ type: "spring", stiffness: 300, damping: 40 }}
onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)}
className="fixed left-0 top-16 h-[calc(100vh-4rem)] bg-white border-r border-gray-200 shadow-lg overflow-x-hidden"
>
<div className="p-4">
{navItems.map((item, index) => (
<button
key={index}
onClick={() => onNavigate(item, index)}
className={`w-full flex ${!isHovered ? 'justify-center' : 'items-center'} px-5 py-2.5 mb-1 rounded-lg transition-all duration-200 ${selectedSection === index
? "bg-blue-50 text-blue-600 shadow-sm"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<span className="flex-shrink-0">{item.icon}</span>
{isHovered && (
<>
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="ml-3 font-medium flex-1 truncate"
>
{item.label}
</motion.span>
</>
)}
</button>
))}
</div>
</motion.nav>
);
}

View File

@ -0,0 +1,28 @@
import { AcademicCapIcon, BookOpenIcon, Cog6ToothIcon, VideoCameraIcon } from '@heroicons/react/24/outline';
import { NavItem } from '@nicestack/client';
export const DEFAULT_NAV_ITEMS: (NavItem & { isCompleted?: boolean })[] = [
{
label: "课程概述",
icon: <BookOpenIcon className="w-5 h-5" />,
path: "/manage/overview"
},
{
label: "目标学员",
icon: <AcademicCapIcon className="w-5 h-5" />,
path: "/manage/overview"
},
{
label: "课程内容",
icon: <VideoCameraIcon className="w-5 h-5" />,
path: "/manage/content"
},
{
label: "课程设置",
icon: <Cog6ToothIcon className="w-5 h-5" />,
path: "/manage/settings"
},
];

View File

@ -1,19 +1,17 @@
import { Button, Form, Input, message, Select, Spin } from "antd";
import { useContext, useEffect} from "react";
import { ObjectType, Role, RolePerms } from "@nicestack/common";
import { Form, Input, message, Select, Spin } from "antd";
import { useContext, useEffect } from "react";
import { Role, RolePerms } from "@nicestack/common";
import { useRole } from "@nicestack/client";
import { api } from "@nicestack/client";
import { RoleEditorContext } from "./role-editor";
const options: { value: string; label: string }[] = Object.values(RolePerms).map((permission) => ({
value: permission,
label: permission,
}));
export default function RoleForm() {
const { editRoleId, roleForm, setRoleModalOpen } = useContext(RoleEditorContext)
const { data, isLoading }: { data: Role, isLoading: boolean } = api.role.findById.useQuery(
{ id: editRoleId },
const { data, isLoading }: { data: Role, isLoading: boolean } = api.role.findFirst.useQuery(
{ where: { id: editRoleId } },
{ enabled: !!editRoleId }
);
useEffect(() => {
@ -33,10 +31,9 @@ export default function RoleForm() {
layout="vertical"
requiredMark="optional"
onFinish={async (values) => {
if (data) {
try {
await update.mutateAsync({ id: data.id, ...values });
await update.mutateAsync({ where: { id: data.id }, data: { ...values } });
} catch (err: any) {
message.error("更新失败");
}

View File

@ -1,22 +1,19 @@
import { ObjectType, Role, RolePerms } from "@nicestack/common"
import { DeleteOutlined, EditFilled, EditOutlined, EllipsisOutlined, PlusOutlined, UserOutlined } from "@ant-design/icons";
import { Role, RolePerms } from "@nicestack/common"
import { DeleteOutlined, EditOutlined, EllipsisOutlined, PlusOutlined, UserOutlined } from "@ant-design/icons";
import { useRole } from "@nicestack/client";
import { Button, theme } from "antd";
import { useContext, useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo } from "react";
import { RoleEditorContext } from "./role-editor";
import { api } from "@nicestack/client"
import { useAuth } from "@web/src/providers/auth-provider";
import { Menu, MenuItem } from "@web/src/components/presentation/dropdown-menu";
const OpreationRenderer = ({ data }: { data: Role }) => {
const { deleteMany } = useRole()
const { editRoleId, setEditRoleId, setRoleModalOpen } = useContext(RoleEditorContext)
const { softDeleteByIds } = useRole()
const { setEditRoleId, setRoleModalOpen } = useContext(RoleEditorContext)
return (
<div>
<Menu
node={
<EllipsisOutlined className=" hover:bg-textHover p-1 rounded" />
}>
node={<EllipsisOutlined className=" hover:bg-textHover p-1 rounded" />}>
<MenuItem
label="编辑"
onClick={() => {
@ -28,7 +25,7 @@ const OpreationRenderer = ({ data }: { data: Role }) => {
label="移除"
disabled={data?.system}
onClick={() => {
deleteMany.mutateAsync({
softDeleteByIds.mutateAsync({
ids: [data?.id],
});
}}
@ -38,7 +35,7 @@ const OpreationRenderer = ({ data }: { data: Role }) => {
);
};
export default function RoleList() {
const { editRoleId, setEditRoleId, setRoleModalOpen } = useContext(RoleEditorContext)
const { setRoleModalOpen } = useContext(RoleEditorContext)
const { setRole, role } = useContext(RoleEditorContext)
const { data: roles } = api.role.findMany.useQuery({})
const { token } = theme.useToken()

View File

@ -11,7 +11,13 @@ interface RoleSelectProps {
export default function RoleSelect({ value, onChange, style, multiple }: RoleSelectProps) {
const [keyword, setQuery] = useState<string>('');
const { data, isLoading } = api.role.findMany.useQuery({ keyword });
const { data, isLoading } = api.role.findMany.useQuery({
where: {
OR: [
{ name: { contains: keyword } }
]
}
});
const handleSearch = (value: string) => {
setQuery(value);

View File

@ -0,0 +1,75 @@
import { motion } from "framer-motion";
export const EmptyStateIllustration = () => {
return (
<motion.svg
width="240"
height="200"
viewBox="0 0 240 200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: "easeOut" }}
>
{/* Background Elements */}
<motion.path
d="M40 100C40 60 60 20 120 20C180 20 200 60 200 100C200 140 180 180 120 180C60 180 40 140 40 100Z"
fill="#F3F4F6"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
/>
{/* Books Stack */}
<motion.g
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.4 }}
>
{/* Bottom Book */}
<path
d="M90 120H150C152.761 120 155 117.761 155 115V105C155 102.239 152.761 100 150 100H90C87.2386 100 85 102.239 85 105V115C85 117.761 87.2386 120 90 120Z"
fill="#E0E7FF"
/>
{/* Middle Book */}
<path
d="M95 100H155C157.761 100 160 97.7614 160 95V85C160 82.2386 157.761 80 155 80H95C92.2386 80 90 82.2386 90 85V95C90 97.7614 92.2386 100 95 100Z"
fill="#818CF8"
/>
{/* Top Book */}
<path
d="M100 80H160C162.761 80 165 77.7614 165 75V65C165 62.2386 162.761 60 160 60H100C97.2386 60 95 62.2386 95 65V75C95 77.7614 97.2386 80 100 80Z"
fill="#6366F1"
/>
</motion.g>
{/* Floating Elements */}
<motion.g
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.6 }}
>
{/* Small Circles */}
<circle cx="70" cy="60" r="4" fill="#C7D2FE" />
<circle cx="180" cy="140" r="6" fill="#818CF8" />
<circle cx="160" cy="40" r="5" fill="#6366F1" />
<circle cx="60" cy="140" r="5" fill="#E0E7FF" />
</motion.g>
{/* Decorative Lines */}
<motion.g
stroke="#C7D2FE"
strokeWidth="2"
strokeLinecap="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 1, delay: 0.8 }}
>
<line x1="40" y1="80" x2="60" y2="80" />
<line x1="180" y1="120" x2="200" y2="120" />
<line x1="160" y1="160" x2="180" y2="160" />
</motion.g>
</motion.svg>
);
};

View File

@ -0,0 +1,28 @@
import { motion } from 'framer-motion';
import { ReactNode } from 'react';
interface CardProps {
children: ReactNode;
className?: string;
hover?: boolean;
onClick?: () => void;
}
export const Card = ({ children, className = '', hover = true, onClick }: CardProps) => {
return (
<motion.div
whileHover={hover ? { y: -5, transition: { duration: 0.2 } } : undefined}
className={`
bg-white dark:bg-gray-800
rounded-xl shadow-lg
overflow-hidden
border border-gray-100 dark:border-gray-700
${hover ? 'cursor-pointer' : ''}
${className}
`}
onClick={onClick}
>
{children}
</motion.div>
);
};

View File

@ -0,0 +1,89 @@
import { useFormContext } from 'react-hook-form';
import { PlusIcon, XMarkIcon, Bars3Icon } from '@heroicons/react/24/outline';
import { Reorder } from 'framer-motion';
import { v4 as uuidv4 } from 'uuid';
import { useState } from 'react';
import FormError from './FormError';
interface ArrayFieldProps {
name: string;
label: string;
placeholder?: string;
addButtonText?: string;
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
}
type ItemType = { id: string; value: string };
const inputStyles = "w-full rounded-md border border-gray-300 bg-white p-2 outline-none transition-all duration-300 ease-out focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:ring-opacity-50 placeholder:text-gray-400 shadow-sm";
const buttonStyles = "rounded-md inline-flex items-center gap-1 px-4 py-2 text-sm font-medium transition-colors ";
export function FormArrayField({ name,
label,
placeholder,
addButtonText = "添加项目",
inputProps = {} }: ArrayFieldProps) {
const { register, watch, setValue, formState: { errors }, trigger } = useFormContext();
const [items, setItems] = useState<ItemType[]>(() =>
(watch(name) as string[])?.map(value => ({ id: uuidv4(), value })) || []
);
const error = errors[name]?.message as string;
const updateItems = (newItems: ItemType[]) => {
setItems(newItems);
setValue(name, newItems.map(item => item.value));
};
return (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">{label}</label>
<div className="space-y-3">
<Reorder.Group axis="y" values={items} onReorder={updateItems} className="space-y-3">
{items.map((item, index) => (
<Reorder.Item
key={item.id}
value={item}
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="group"
>
<div className="relative flex items-center">
<div className="flex-1 relative">
<input
{...register(`${name}.${index}`)}
{...inputProps}
value={item.value}
onChange={e => updateItems(
items.map(i => i.id === item.id ? { ...i, value: e.target.value } : i)
)}
onBlur={() => trigger(name)}
placeholder={placeholder}
className={inputStyles}
/>
{inputProps.maxLength && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-gray-400">
{inputProps.maxLength - (item.value?.length || 0)}
</span>
)}
</div>
<button
type="button"
onClick={() => updateItems(items.filter(i => i.id !== item.id))}
className="absolute -right-10 p-2 text-gray-400 hover:text-red-500 transition-colors rounded-md hover:bg-red-50 focus:ring-red-200 opacity-0 group-hover:opacity-100"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
</Reorder.Item>
))}
</Reorder.Group>
<button
type="button"
onClick={() => updateItems([...items, { id: uuidv4(), value: '' }])}
className={`${buttonStyles} text-blue-600 bg-blue-50 hover:bg-blue-100 focus:ring-blue-500`}
>
<PlusIcon className="w-4 h-4" />
{addButtonText}
</button>
</div>
<FormError error={error}></FormError>
</div>
);
}

View File

@ -0,0 +1,22 @@
import { AnimatePresence, motion } from "framer-motion";
const ANIMATIONS = {
error: {
initial: { opacity: 0, y: -8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -8 },
transition: { duration: 0.2 }
}
};
export default function FormError({ error }: { error: string }) {
return <AnimatePresence>
{error && (
<motion.span {...ANIMATIONS.error}
className="absolute left-0 top-full mt-1 text-sm font-medium text-red-500"
>
{error}
</motion.span>
)}
</AnimatePresence>
}

View File

@ -0,0 +1,88 @@
import { useFormContext } from 'react-hook-form';
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
import FormError from './FormError';
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'type'> {
name: string;
label: string;
type?: 'text' | 'textarea' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local';
rows?: number;
}
export function FormInput({
name,
label,
type = 'text',
rows = 4,
className,
...restProps
}: FormInputProps) {
const [isFocused, setIsFocused] = useState(false);
const {
register,
formState: { errors },
watch,
setValue,
trigger, // Add trigger from useFormContext
} = useFormContext();
const handleBlur = async () => {
setIsFocused(false);
await trigger(name); // Trigger validation for this field
};
const value = watch(name);
const error = errors[name]?.message as string;
const isValid = value && !error;
const inputClasses = `
w-full rounded-md border bg-white p-2 pr-8 outline-none shadow-sm
transition-all duration-300 ease-out placeholder:text-gray-400
${error ? 'border-red-500 focus:border-red-500 focus:ring-red-200' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-200'}
${isFocused ? 'ring-2 ring-opacity-50' : ''}
${className || ''}
`;
const InputElement = type === 'textarea' ? 'textarea' : 'input';
return (
<div className="space-y-2">
<div className="flex justify-between">
<label className="block text-sm font-medium text-gray-700">{label}</label>
{restProps.maxLength && (
<span className="text-sm text-gray-500">
{value?.length || 0}/{restProps.maxLength}
</span>
)}
</div>
<div className="relative">
<InputElement
{...register(name)}
type={type !== 'textarea' ? type : undefined}
rows={type === 'textarea' ? rows : undefined}
{...restProps}
onFocus={() => setIsFocused(true)}
onBlur={handleBlur}
className={inputClasses}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center space-x-1">
{value && isFocused && (
<button
type="button"
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setValue(name, '')}
>
<XMarkIcon className="w-4 h-4" />
</button>
)}
{isValid && <CheckIcon className="text-green-500 w-4 h-4" />}
</div>
<FormError error={error}></FormError>
</div>
</div>
);
}

View File

@ -0,0 +1,117 @@
import { useFormContext } from 'react-hook-form';
import { AnimatePresence, motion } from 'framer-motion';
import { useEffect, useState, useRef } from 'react';
import { ChevronUpDownIcon, CheckIcon } from '@heroicons/react/24/outline';
import FormError from './FormError';
interface Option {
value: string;
label: string;
}
interface FormSelectProps {
name: string;
label: string;
options: Option[];
placeholder?: string;
}
const ANIMATIONS = {
dropdown: {
initial: { opacity: 0, y: -4 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -4 },
transition: { duration: 0.15 }
},
error: {
initial: { opacity: 0, y: -8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -8 },
transition: { duration: 0.2 }
}
};
export function FormSelect({ name, label, options, placeholder = '请选择' }: FormSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const {
register,
formState: { errors },
watch,
setValue,
} = useFormContext();
const value = watch(name);
const error = errors[name]?.message as string;
const selectedOption = options.find(opt => opt.value === value);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!dropdownRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const getInputClasses = (hasError: boolean) => `
w-full rounded-md border bg-white
transition-all duration-300 ease-out
p-2 pr-8 outline-none cursor-pointer
${hasError ? 'border-red-500 focus:border-red-500 focus:ring-red-200'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-200'}
${isOpen ? 'ring-2 ring-opacity-50' : ''}
placeholder:text-gray-400 shadow-sm
`;
return (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{label}
</label>
<div className="relative" ref={dropdownRef}>
<input type="hidden" {...register(name)} />
<div
className={getInputClasses(!!error)}
onClick={() => setIsOpen(!isOpen)}
>
{selectedOption?.label || <span className="text-gray-400">{placeholder}</span>}
</div>
<ChevronUpDownIcon
className={`absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5
text-gray-400 transition-transform duration-200
${isOpen ? 'transform rotate-180' : ''}`}
/>
<AnimatePresence>
{isOpen && (
<motion.div {...ANIMATIONS.dropdown}
className="absolute z-10 w-full mt-1 bg-white rounded-md
shadow-lg border border-gray-200 max-h-60 overflow-auto"
>
{options.map((option) => (
<div
key={option.value}
className={`p-2 cursor-pointer flex items-center justify-between
${value === option.value ? 'bg-blue-50 text-blue-600' : 'hover:bg-gray-50'}`}
onClick={() => {
setValue(name, option.value);
setIsOpen(false);
}}
>
{option.label}
{value === option.value && <CheckIcon className="w-4 h-4" />}
</div>
))}
</motion.div>
)}
</AnimatePresence>
<FormError error={error}></FormError>
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import { EmptyStateIllustration } from "../EmptyStateIllustration";
interface EmptyStateProps {
title?: string;
description?: string;
illustration?: React.ReactNode;
}
export const EmptyState = ({
title = "暂无数据",
description = "当前列表为空,请稍后再试",
illustration: Illustration = <EmptyStateIllustration></EmptyStateIllustration>
}: EmptyStateProps) => {
return (
<div className="flex flex-col items-center justify-center">
{Illustration}
<h3 className="mb-2 text-xl font-medium text-slate-800">
{title}
</h3>
<p className="text-slate-500">
{description}
</p>
</div>
);
};

View File

@ -0,0 +1,50 @@
import { useMemo } from 'react';
interface AvatarProps {
src?: string;
name?: string;
size?: number;
className?: string;
}
export function Avatar({ src, name = '', size = 40, className = '' }: AvatarProps) {
const initials = useMemo(() => {
return name
.split(/\s+|(?=[A-Z])/)
.map(word => word[0])
.slice(0, 2)
.join('')
.toUpperCase();
}, [name]);
const backgroundColor = useMemo(() => {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = hash % 360;
return `hsl(${hue}, 70%, 50%)`;
}, [name]);
return (
<div
className={`relative rounded-full overflow-hidden ${className}`}
style={{ width: size, height: size }}
>
{src ? (
<img
src={src}
alt={name}
className="w-full h-full object-cover"
/>
) : (
<div
className="w-full h-full flex items-center justify-center text-white font-medium"
style={{ backgroundColor }}
>
<span style={{ fontSize: `${size * 0.4}px` }}>{initials}</span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,14 @@
import { useEffect, RefObject } from 'react';
export function useClickOutside(ref: RefObject<HTMLElement>, handler: () => void) {
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
handler();
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [ref, handler]);
}

View File

@ -10,10 +10,15 @@ import DepartmentAdminPage from "../app/admin/department/page";
import TermAdminPage from "../app/admin/term/page";
import StaffAdminPage from "../app/admin/staff/page";
import RoleAdminPage from "../app/admin/role/page";
import MainLayoutPage from "../app/layout";
import WithAuth from "../components/utils/with-auth";
import LoginPage from "../app/login";
import BaseSettingPage from "../app/admin/base-setting/page";
import CoursesPage from "../app/main/courses/page";
import { CoursePage } from "../app/main/course/page";
import { CourseEditorPage } from "../app/main/course/editor/page";
import { MainLayout } from "../components/layout/main/MainLayout";
interface CustomIndexRouteObject extends IndexRouteObject {
name?: string;
breadcrumb?: string;
@ -37,7 +42,6 @@ export type CustomRouteObject =
export const routes: CustomRouteObject[] = [
{
path: "/",
element: <MainLayoutPage></MainLayoutPage>,
errorElement: <ErrorPage />,
handle: {
crumb() {
@ -45,6 +49,18 @@ export const routes: CustomRouteObject[] = [
},
},
children: [
{
path: "courses",
index: true,
element: <WithAuth><MainLayout><CoursesPage></CoursesPage></MainLayout></WithAuth>
},
{
path: "course",
children: [{
path: "manage",
element: <CourseEditorPage></CourseEditorPage>
}]
},
{
path: "admin",
children: [

View File

@ -6,7 +6,7 @@ services:
ports:
- "5432:5432"
environment:
- POSTGRES_DB=defender_app
- POSTGRES_DB=app
- POSTGRES_USER=root
- POSTGRES_PASSWORD=Letusdoit000
volumes:
@ -81,14 +81,14 @@ services:
# environment:
# - VITE_APP_SERVER_IP=192.168.79.77
# - VITE_APP_VERSION=0.3.0
# - VITE_APP_APP_NAME=两道防线管理后台
# - VITE_APP_APP_NAME=烽火慕课
# server:
# image: td-server:latest
# ports:
# - "3000:3000"
# - "3001:3001"
# environment:
# - DATABASE_URL=postgresql://root:Letusdoit000@db:5432/defender_app?schema=public
# - DATABASE_URL=postgresql://root:Letusdoit000@db:5432/app?schema=public
# - REDIS_HOST=redis
# - REDIS_PORT=6379
# - REDIS_PASSWORD=Letusdoit000

View File

@ -8,4 +8,5 @@ export * from "./useTransform"
export * from "./useTaxonomy"
export * from "./useVisitor"
export * from "./useMessage"
export * from "./usePost"
export * from "./usePost"
export * from "./useCourse"

View File

@ -0,0 +1,49 @@
import { api } from "../trpc";
export function useCourse() {
const utils = api.useUtils();
return {
// Queries
findMany: api.course.findMany.useQuery,
findFirst: api.course.findFirst.useQuery,
findManyWithCursor: api.course.findManyWithCursor.useQuery,
// Mutations
create: api.course.create.useMutation({
onSuccess: () => {
utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate();
},
}),
update: api.course.update.useMutation({
onSuccess: () => {
utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate();
},
}),
createMany: api.course.createMany.useMutation({
onSuccess: () => {
utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate();
},
}),
deleteMany: api.course.deleteMany.useMutation({
onSuccess: () => {
utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate();
},
}),
softDeleteByIds: api.course.softDeleteByIds.useMutation({
onSuccess: () => {
utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate();
},
}),
updateOrder: api.course.updateOrder.useMutation({
onSuccess: () => {
utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate();
},
})
};
}

View File

@ -1,37 +1,33 @@
import { getQueryKey } from "@trpc/react-query";
import { api } from "../trpc"; // Adjust path as necessary
import { useQueryClient } from "@tanstack/react-query";
import { api } from "../trpc";
export function useRole() {
const queryClient = useQueryClient();
const queryKey = getQueryKey(api.role);
const create = api.role.create.useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
},
});
const update = api.role.update.useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
},
});
const deleteMany = api.role.deleteMany.useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey })
}
})
const paginate = (page: number, pageSize: number) => {
return api.role.paginate.useQuery({ page, pageSize });
};
const utils = api.useUtils();
return {
create,
update,
paginate,
deleteMany
// Create mutations
create: api.role.create.useMutation({
onSuccess: () => utils.role.findMany.invalidate(),
}),
createMany: api.role.createMany.useMutation({
onSuccess: () => utils.role.findMany.invalidate(),
}),
update: api.role.update.useMutation({
onSuccess: () => utils.role.findMany.invalidate(),
}),
// Delete mutation
softDeleteByIds: api.role.softDeleteByIds.useMutation({
onSuccess: () => utils.role.findMany.invalidate(),
}),
// Update mutation
updateOrder: api.role.updateOrder.useMutation({
onSuccess: () => utils.role.findMany.invalidate(),
}),
// Queries
findFirst: api.role.findFirst.useQuery,
findMany: api.role.findMany.useQuery,
findManyWithCursor: api.role.findManyWithCursor.useQuery,
findManyWithPagination: api.role.findManyWithPagination.useQuery,
};
}
}

View File

@ -5,4 +5,5 @@ export * from "./io"
export * from "./providers"
export * from "./hooks"
export * from "./websocket"
export * from "./event"
export * from "./event"
export * from "./types"

View File

@ -0,0 +1,5 @@
export interface NavItem {
icon?: React.ReactNode;
label: string;
path: string;
}

View File

@ -1 +1 @@
DATABASE_URL="postgresql://root:Letusdoit000@localhost:5432/defender_app?schema=public"
DATABASE_URL="postgresql://root:Letusdoit000@localhost:5432/app?schema=public"

View File

@ -99,14 +99,13 @@ model Staff {
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")
watchedPost Post[] @relation("post_watch_staff")
visits Visit[]
posts Post[]
sentMsgs Message[] @relation("message_sender")
receivedMsgs Message[] @relation("message_receiver")
registerToken String?
enrollments Enrollment[]
courseReviews CourseReview[]
teachedCourses CourseInstructor[]
@@index([officerId])
@ -197,32 +196,43 @@ model AppConfig {
}
model Post {
id String @id @default(cuid())
type String?
title String?
content String?
author Staff? @relation(fields: [authorId], references: [id])
authorId String?
domainId String?
referenceId String?
attachments String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
visits Visit[]
watchableStaffs Staff[] @relation("post_watch_staff")
watchableDepts Department[] @relation("post_watch_dept")
// 字符串类型字段
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") // 删除时间,可为空
parentId String?
parent Post? @relation("PostChildren", fields: [parentId], references: [id])
children Post[] @relation("PostChildren")
deletedAt DateTime? @map("deleted_at")
Lecture Lecture? @relation(fields: [lectureId], references: [id])
lectureId String?
// 整数类型字段
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([referenceId, type]) // 引用ID和类型组合查询
@@index([parentId, type]) // 父级帖子和创建时间索引
// 时间相关索引
@@index([createdAt]) // 按创建时间倒序索引
@ -230,284 +240,244 @@ model Post {
}
model Message {
id String @id @default(cuid())
url String?
intent String?
option Json?
senderId String? @map("sender_id")
messageType 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")
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([messageType, createdAt])
@@index([type, createdAt])
@@map("message")
}
model Visit {
id String @id @default(cuid())
visitType String? @map("visit_type")
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")
views Int @default(1)
createdAt DateTime? @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
sourceIP String? @map("source_ip")
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")
enrollment Enrollment? @relation(fields: [enrollmentId], references: [id], onDelete: Cascade)
enrollmentId String? @map("enrollment_id") // 报名记录ID
lecture Lecture? @relation(fields: [lectureId], references: [id], onDelete: Cascade)
lectureId String? @map("lecture_id") // 课时ID
@@index([postId, visitType, visitorId])
@@index([messageId, visitType, visitorId])
// 学习数据
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") // 更新时间
@@unique([enrollmentId, lectureId]) // 确保每个报名只有一条课时进度
@@index([isCompleted]) // 完成状态索引
@@index([lastWatchedAt]) // 最后观看时间索引
@@index([postId, type, visitorId])
@@index([messageId, type, visitorId])
@@map("visit")
}
model Course {
id String @id @default(cuid()) // 课程唯一标识符
title String // 课程标题
subTitle String? // 课程副标题(可选)
description String // 课程详细描述
thumbnail String? // 课程封面图片URL(可选)
level String // 课程难度等级
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[] // 课程章节结构
enrollments Enrollment[] // 学生报名记录
reviews CourseReview[] // 学员课程评价
reviews Post[] // 学员课程评价
// 课程规划与目标设定
requirements String[] // 课程学习前置要求
objectives String[] // 具体的学习目标
skills String[] // 课程结束后可掌握的技能
audiences String[] // 目标受众群体描述
requirements String[] @map("requirements") // 课程学习前置要求
objectives String[] @map("objectives") // 具体的学习目标
skills String[] @map("skills") // 课程结束后可掌握的技能
audiences String[] @map("audiences") // 目标受众群体描述
// 课程统计指标
totalDuration Int @default(0) // 课程总时长(分钟)
totalLectures Int @default(0) // 总课时数
averageRating Float @default(0) // 平均评分(1-5分)
numberOfReviews Int @default(0) // 评价总数
numberOfStudents Int @default(0) // 学习人数
completionRate Float @default(0) // 完课率(0-100%)
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%)
// 课程状态管理
status String // 课程状态(如:草稿/已发布/已归档)
isFeatured Boolean @default(false) // 是否为精选推荐课程
status String? @map("status") // 课程状态(如:草稿/已发布/已归档)
isFeatured Boolean? @default(false) @map("is_featured") // 是否为精选推荐课程
// 生命周期时间戳
createdAt DateTime @default(now()) // 创建时间
updatedAt DateTime @updatedAt // 最后更新时间
publishedAt DateTime? // 发布时间
archivedAt DateTime? // 归档时间
deletedAt DateTime? // 软删除时间
createdAt DateTime? @default(now()) @map("created_at") // 创建时间
updatedAt DateTime? @updatedAt @map("updated_at") // 最后更新时间
publishedAt DateTime? @map("published_at") // 发布时间
archivedAt DateTime? @map("archived_at") // 归档时间
deletedAt DateTime? @map("deleted_at") // 软删除时间
// 数据库索引优化
@@index([status]) // 课程状态索引,用于快速筛选
@@index([level]) // 难度等级索引,用于分类查询
@@index([isFeatured]) // 精选标记索引,用于首页推荐
@@map("course")
}
model Section {
id String @id @default(cuid()) // 章节唯一标识符
title String // 章节标题
description String? // 章节描述(可选)
objectives String[] // 本章节的具体学习目标
order Float? @default(0) // 章节排序权重
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")
totalDuration Int @default(0) @map("total_duration")
totalLectures Int @default(0) @map("total_lectures")
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 // 所属课程ID
lectures Lecture[] // 包含的所有课时
courseId String @map("course_id")
lectures Lecture[]
// 章节统计数据
totalDuration Int @default(0) // 本章节总时长(分钟)
totalLectures Int @default(0) // 本章节课时总数
// 时间管理
createdAt DateTime @default(now()) // 创建时间
updatedAt DateTime @updatedAt // 更新时间
deletedAt DateTime? // 软删除时间
@@index([courseId, order]) // 复合索引:用于按课程ID和顺序快速查询
@@index([courseId, order])
@@map("section")
}
model Lecture {
id String @id @default(cuid()) // 课时唯一标识符
title String // 课时标题
description String? // 课时描述(可选)
order Float? @default(0) // 课时排序权重
duration Int // 学习时长(分钟)
type String // 课时类型(video/article)
id String @id @default(cuid()) @map("id")
title String @map("title")
description String? @map("description")
order Float? @default(0) @map("order")
duration Int @map("duration")
type String @map("type")
content String? @map("content")
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")
// 课时内容
content String? // Markdown格式文章内容
videoUrl String? // 视频URL地址
videoThumbnail String? // 视频封面图URL
// 关联关系
resources Resource[]
section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade)
sectionId String @map("section_id")
comments Post[]
visits Visit[]
// 关联内容
resources Resource[] // 课时附属资源
section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade)
sectionId String // 所属章节ID
comments Post[] // 课时评论
progress LectureProgress[] // 学习进度记录
// 时间管理
publishedAt DateTime? // 发布时间
createdAt DateTime @default(now()) // 创建时间
updatedAt DateTime @updatedAt // 更新时间
deletedAt DateTime? // 软删除时间
@@index([sectionId, order]) // 章节内课时排序索引
@@index([type, publishedAt]) // 课时类型和发布时间复合索引
@@index([sectionId, order])
@@index([type, publishedAt])
@@map("lecture")
}
model Enrollment {
id String @id @default(cuid()) // 报名记录唯一标识符
status String // 报名状态(如:进行中/已完成/已过期)
id String @id @default(cuid()) @map("id")
status String @map("status")
completionRate Float @default(0) @map("completion_rate")
lastAccessedAt DateTime? @map("last_accessed_at")
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 // 学员ID
course Course @relation(fields: [courseId], references: [id])
courseId String // 课程ID
progress LectureProgress[] // 课时学习进度记录
student Staff @relation(fields: [studentId], references: [id])
studentId String @map("student_id")
course Course @relation(fields: [courseId], references: [id])
courseId String @map("course_id")
visits Visit[]
// 学习数据统计
completionRate Float @default(0) // 课程完成度(0-100%)
lastAccessedAt DateTime? // 最后访问时间
// 时间管理
createdAt DateTime @default(now()) // 报名时间
updatedAt DateTime @updatedAt // 更新时间
completedAt DateTime? // 完课时间
@@unique([studentId, courseId]) // 确保学员不会重复报名同一课程
@@index([status]) // 报名状态索引
@@index([completedAt]) // 完课时间索引
}
model LectureProgress {
id String @id @default(cuid()) // 进度记录唯一标识符
progress Float @default(0) // 完成进度(0-100%)
isCompleted Boolean @default(false) // 是否完成
// 关联关系
enrollment Enrollment @relation(fields: [enrollmentId], references: [id], onDelete: Cascade)
enrollmentId String // 报名记录ID
lecture Lecture @relation(fields: [lectureId], references: [id], onDelete: Cascade)
lectureId String // 课时ID
// 学习数据
lastPosition Int @default(0) // 视频播放位置(秒)
viewCount Int @default(0) // 观看次数
readCount Int @default(0) // 阅读次数
totalWatchTime Int @default(0) // 总观看时长(秒)
// 时间记录
lastWatchedAt DateTime? // 最后观看时间
createdAt DateTime @default(now()) // 创建时间
updatedAt DateTime @updatedAt // 更新时间
@@unique([enrollmentId, lectureId]) // 确保每个报名只有一条课时进度
@@index([isCompleted]) // 完成状态索引
@@index([lastWatchedAt]) // 最后观看时间索引
@@unique([studentId, courseId])
@@index([status])
@@index([completedAt])
@@map("enrollment")
}
model CourseInstructor {
course Course @relation(fields: [courseId], references: [id])
courseId String // 课程ID
instructor Staff @relation(fields: [instructorId], references: [id])
instructorId String // 讲师ID
role String // 讲师角色
createdAt DateTime @default(now()) // 创建时间
order Float? @default(0) // 讲师显示顺序
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")
@@id([courseId, instructorId]) // 联合主键
course Course @relation(fields: [courseId], references: [id])
instructor Staff @relation(fields: [instructorId], references: [id])
@@id([courseId, instructorId])
@@map("course_instructor")
}
model CourseReview {
id String @id @default(cuid()) // 评价唯一标识符
rating Int // 评分(1-5星)
content String? // 评价内容
student Staff @relation(fields: [studentId], references: [id])
studentId String // 评价学员ID
course Course @relation(fields: [courseId], references: [id])
courseId String // 课程ID
helpfulCount Int @default(0) // 评价点赞数
createdAt DateTime @default(now()) // 评价时间
updatedAt DateTime @updatedAt // 更新时间
@@unique([studentId, courseId]) // 确保学员对同一课程只能评价一次
@@index([rating]) // 评分索引
}
model Resource {
id String @id @default(cuid()) // 资源唯一标识符
title String // 资源标题
description String? // 资源描述
type String // 资源类型
url String // 资源URL
id String @id @default(cuid()) @map("id")
title String @map("title")
description String? @map("description")
type String @map("type")
url String @map("url")
fileType String? @map("file_type")
fileSize Int? @map("file_size")
downloadCount Int @default(0) @map("download_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
fileType String? // 文件MIME类型
fileSize Int? // 文件大小(bytes)
lectures Lecture[]
posts Post[]
lecture Lecture @relation(fields: [lectureId], references: [id], onDelete: Cascade)
lectureId String // 所属课时ID
downloadCount Int @default(0) // 下载次数
createdAt DateTime @default(now()) // 创建时间
updatedAt DateTime @updatedAt // 更新时间
@@index([lectureId, type]) // 课时资源类型复合索引
@@index([type])
@@map("resource")
}
model Node {
id String @id @default(cuid())
title String
description String?
type String
// 节点之间的关系
sourceEdges NodeEdge[] @relation("from_node")
targetEdges NodeEdge[] @relation("to_node")
style Json?
position Json? // 存储节点在画布中的位置 {x: number, y: number}
data Json?
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")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 关联关系
sourceEdges NodeEdge[] @relation("source_node")
targetEdges NodeEdge[] @relation("target_node")
@@map("node")
}
// 节点之间的关系
model NodeEdge {
id String @id @default(cuid())
// 关系的起点和终点
source Node @relation("from_node", fields: [sourceId], references: [id], onDelete: Cascade)
sourceId String
target Node @relation("to_node", fields: [targetId], references: [id], onDelete: Cascade)
targetId String
// 关系属性
type String?
label String?
description String?
// 自定义边的样式(可选)
style Json? // 存储边的样式,如 {color: string, strokeWidth: number}
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
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

@ -1,18 +1,19 @@
import { PrismaClient } from "@prisma/client";
let prisma: PrismaClient | null = null;
const createPrismaClient = () => {
return new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
};
export const db = (() => {
if (typeof window === 'undefined') {
if (!prisma) {
prisma = createPrismaClient();
}
return prisma;
} else {
// Optional: You can throw an error or return null to indicate that this should not be used on the client side.
return null
}
})();
throw new Error('PrismaClient is not available in browser environment');
})() as PrismaClient;

View File

@ -27,6 +27,11 @@ export enum ObjectType {
MESSAGE = "message",
POST = "post",
VISIT = "visit",
COURSE = "course",
SECTION = "section",
LECTURE = "lecture",
ENROLLMENT = "enrollment",
RESOURCE = "resource"
}
export enum RolePerms {
// Create Permissions 创建权限

View File

@ -44,8 +44,8 @@ export const UpdateOrderSchema = z.object({
overId: z.string(),
});
export const RowRequestSchema = z.object({
startRow: z.number().nullish(),
endRow: z.number().nullish(),
startRow: z.number(),
endRow: z.number(),
rowGroupCols: z.array(
z.object({
id: z.string(),
@ -58,12 +58,12 @@ export const RowRequestSchema = z.object({
id: z.string().nullish(),
displayName: z.string().nullish(),
aggFunc: z.string().nullish(),
field: z.string().nullish(),
field: z.string(),
})
),
pivotCols: z.array(z.any()).nullish(),
pivotMode: z.boolean().nullish(),
groupKeys: z.array(z.any()).nullish(),
groupKeys: z.array(z.any()),
filterModel: z.any().nullish(),
sortModel: z.array(SortModel).nullish(),
includeDeleted: z.boolean().nullish()
@ -186,7 +186,7 @@ export const TransformMethodSchema = {
domainId: z.string().nullish(),
parentId: z.string().nullish(),
}),
};
export const TermMethodSchema = {
getRows: RowRequestSchema.extend({

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