12311557
This commit is contained in:
parent
3e47150b1a
commit
cfa4be626d
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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' }],
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 { }
|
|
@ -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);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 { }
|
|
@ -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);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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 { }
|
|
@ -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);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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) ||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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 [];
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import {
|
||||
ChangedRows,
|
||||
ObjectType,
|
||||
RoleMapMethodSchema,
|
||||
} from '@nicestack/common';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 { }
|
|
@ -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);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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 { }
|
|
@ -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);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}),
|
||||
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 }],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 } })
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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: [],
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
VITE_APP_VERSION: "$VITE_APP_VERSION",
|
||||
};
|
||||
</script>
|
||||
<title>两道防线管理后台</title>
|
||||
<title>烽火慕课</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import CourseEditor from "@web/src/components/models/course/manage/CourseEditor";
|
||||
|
||||
export function CourseEditorPage() {
|
||||
return <CourseEditor></CourseEditor>
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export default function MainPage() {
|
||||
return <div>main</div>
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
];
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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"
|
||||
},
|
||||
];
|
|
@ -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("更新失败");
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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]);
|
||||
}
|
|
@ -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: [
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
|
@ -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();
|
||||
},
|
||||
})
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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"
|
|
@ -0,0 +1,5 @@
|
|||
export interface NavItem {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
|
@ -1 +1 @@
|
|||
DATABASE_URL="postgresql://root:Letusdoit000@localhost:5432/defender_app?schema=public"
|
||||
DATABASE_URL="postgresql://root:Letusdoit000@localhost:5432/app?schema=public"
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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 创建权限
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue