Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
2bd1941bd8
|
@ -11,4 +11,4 @@ DEADLINE_CRON="0 0 8 * * *"
|
|||
SERVER_PORT=3000
|
||||
ADMIN_PHONE_NUMBER=13258117304
|
||||
NODE_ENV=development
|
||||
UPLOAD_DIR=/opt/projects/remooc/uploads
|
||||
UPLOAD_DIR=/opt/projects/re-mooc/uploads
|
|
@ -1,4 +1,10 @@
|
|||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
@Catch()
|
||||
export class ExceptionsFilter implements ExceptionFilter {
|
||||
|
@ -7,11 +13,13 @@ export class ExceptionsFilter implements ExceptionFilter {
|
|||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status = exception instanceof HttpException
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const message = exception instanceof HttpException
|
||||
const message =
|
||||
exception instanceof HttpException
|
||||
? exception.message
|
||||
: 'Internal server error';
|
||||
|
||||
|
|
|
@ -37,10 +37,8 @@ export class BaseService<
|
|||
constructor(
|
||||
protected prisma: PrismaClient,
|
||||
protected objectType: string,
|
||||
protected enableOrder: boolean = false
|
||||
) {
|
||||
|
||||
}
|
||||
protected enableOrder: boolean = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieves the name of the model dynamically.
|
||||
|
@ -51,7 +49,7 @@ export class BaseService<
|
|||
return modelName;
|
||||
}
|
||||
private getModel(tx?: TransactionType): D {
|
||||
return tx?.[this.objectType] || this.prisma[this.objectType] as D;
|
||||
return tx?.[this.objectType] || (this.prisma[this.objectType] as D);
|
||||
}
|
||||
/**
|
||||
* Error handling helper function
|
||||
|
@ -85,7 +83,9 @@ export class BaseService<
|
|||
*/
|
||||
async findUnique(args: A['findUnique']): Promise<R['findUnique']> {
|
||||
try {
|
||||
return this.getModel().findUnique(args as any) as Promise<R['findUnique']>;
|
||||
return this.getModel().findUnique(args as any) as Promise<
|
||||
R['findUnique']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'read');
|
||||
}
|
||||
|
@ -152,26 +152,27 @@ export class BaseService<
|
|||
* const newUser = await service.create({ data: { name: 'John Doe' } });
|
||||
*/
|
||||
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({
|
||||
orderBy: { order: 'desc' }
|
||||
}) as any;
|
||||
const maxOrderItem = (await this.getModel(params?.tx).findFirst({
|
||||
orderBy: { order: 'desc' },
|
||||
})) as any;
|
||||
// 设置新记录的 order 值
|
||||
const newOrder = maxOrderItem ? maxOrderItem.order + this.ORDER_INTERVAL : 1;
|
||||
const newOrder = maxOrderItem
|
||||
? maxOrderItem.order + this.ORDER_INTERVAL
|
||||
: 1;
|
||||
// 将 order 添加到创建参数中
|
||||
(args as any).data.order = newOrder;
|
||||
}
|
||||
return this.getModel(params?.tx).create(args as any) as Promise<R['create']>;
|
||||
return this.getModel(params?.tx).create(args as any) as Promise<
|
||||
R['create']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'create');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates multiple new records with the given data.
|
||||
* @param args - Arguments to create multiple records.
|
||||
|
@ -179,9 +180,14 @@ export class BaseService<
|
|||
* @example
|
||||
* const newUsers = await service.createMany({ data: [{ name: 'John' }, { name: 'Jane' }] });
|
||||
*/
|
||||
async createMany(args: A['createMany'], params?: any): Promise<R['createMany']> {
|
||||
async createMany(
|
||||
args: A['createMany'],
|
||||
params?: any,
|
||||
): Promise<R['createMany']> {
|
||||
try {
|
||||
return this.getModel(params?.tx).createMany(args as any) as Promise<R['createMany']>;
|
||||
return this.getModel(params?.tx).createMany(args as any) as Promise<
|
||||
R['createMany']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'create');
|
||||
}
|
||||
|
@ -196,8 +202,9 @@ export class BaseService<
|
|||
*/
|
||||
async update(args: A['update'], params?: any): Promise<R['update']> {
|
||||
try {
|
||||
|
||||
return this.getModel(params?.tx).update(args as any) as Promise<R['update']>;
|
||||
return this.getModel(params?.tx).update(args as any) as Promise<
|
||||
R['update']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'update');
|
||||
}
|
||||
|
@ -251,7 +258,9 @@ export class BaseService<
|
|||
*/
|
||||
async delete(args: A['delete'], params?: any): Promise<R['delete']> {
|
||||
try {
|
||||
return this.getModel(params?.tx).delete(args as any) as Promise<R['delete']>;
|
||||
return this.getModel(params?.tx).delete(args as any) as Promise<
|
||||
R['delete']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'delete');
|
||||
}
|
||||
|
@ -309,10 +318,14 @@ export class BaseService<
|
|||
* @example
|
||||
* const deleteResult = await service.deleteMany({ where: { isActive: false } });
|
||||
*/
|
||||
async deleteMany(args: A['deleteMany'], params?: any): Promise<R['deleteMany']> {
|
||||
async deleteMany(
|
||||
args: A['deleteMany'],
|
||||
params?: any,
|
||||
): Promise<R['deleteMany']> {
|
||||
try {
|
||||
|
||||
return this.getModel(params?.tx).deleteMany(args as any) as Promise<R['deleteMany']>;
|
||||
return this.getModel(params?.tx).deleteMany(args as any) as Promise<
|
||||
R['deleteMany']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'delete');
|
||||
}
|
||||
|
@ -327,7 +340,9 @@ export class BaseService<
|
|||
*/
|
||||
async updateMany(args: A['updateMany']): Promise<R['updateMany']> {
|
||||
try {
|
||||
return this.getModel().updateMany(args as any) as Promise<R['updateMany']>;
|
||||
return this.getModel().updateMany(args as any) as Promise<
|
||||
R['updateMany']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'update');
|
||||
}
|
||||
|
@ -420,8 +435,7 @@ export class BaseService<
|
|||
data: { ...data, deletedAt: null } as any,
|
||||
}) as Promise<R['update'][]>;
|
||||
} catch (error) {
|
||||
this.handleError(error, "update");
|
||||
|
||||
this.handleError(error, 'update');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -436,25 +450,25 @@ export class BaseService<
|
|||
page?: number;
|
||||
pageSize?: number;
|
||||
where?: WhereArgs<A['findMany']>;
|
||||
select?: SelectArgs<A['findMany']>
|
||||
select?: SelectArgs<A['findMany']>;
|
||||
}): Promise<{ items: R['findMany']; totalPages: number }> {
|
||||
const { page = 1, pageSize = 10, where, select } = args;
|
||||
|
||||
try {
|
||||
// 获取总记录数
|
||||
const total = await this.getModel().count({ where }) as number;
|
||||
const total = (await this.getModel().count({ where })) as number;
|
||||
// 获取分页数据
|
||||
const items = await this.getModel().findMany({
|
||||
const items = (await this.getModel().findMany({
|
||||
where,
|
||||
select,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
} as any) as R['findMany'];
|
||||
} as any)) as R['findMany'];
|
||||
// 计算总页数
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
return {
|
||||
items,
|
||||
totalPages
|
||||
totalPages,
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleError(error, 'read');
|
||||
|
@ -483,7 +497,6 @@ export class BaseService<
|
|||
: undefined,
|
||||
} as any)) as any[];
|
||||
|
||||
|
||||
/**
|
||||
* 处理下一页游标
|
||||
* @description
|
||||
|
@ -530,7 +543,7 @@ export class BaseService<
|
|||
order: { gt: targetObject.order },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { order: 'asc' }
|
||||
orderBy: { order: 'asc' },
|
||||
} as any)) as any;
|
||||
|
||||
const newOrder = nextObject
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
import { Prisma, PrismaClient } from '@nice/common';
|
||||
import { BaseService } from "./base.service";
|
||||
import { DataArgs, DelegateArgs, DelegateFuncs, DelegateReturnTypes, UpdateOrderArgs } from "./base.type";
|
||||
import { BaseService } from './base.service';
|
||||
import {
|
||||
DataArgs,
|
||||
DelegateArgs,
|
||||
DelegateFuncs,
|
||||
DelegateReturnTypes,
|
||||
UpdateOrderArgs,
|
||||
} from './base.type';
|
||||
|
||||
/**
|
||||
* BaseTreeService provides a generic CRUD interface for a tree prisma model.
|
||||
|
@ -15,24 +21,23 @@ export class BaseTreeService<
|
|||
A extends DelegateArgs<D> = DelegateArgs<D>,
|
||||
R extends DelegateReturnTypes<D> = DelegateReturnTypes<D>,
|
||||
> extends BaseService<D, A, R> {
|
||||
|
||||
constructor(
|
||||
protected prisma: PrismaClient,
|
||||
protected objectType: string,
|
||||
protected ancestryType: string = objectType + 'Ancestry',
|
||||
protected enableOrder: boolean = false
|
||||
protected enableOrder: boolean = false,
|
||||
) {
|
||||
super(prisma, objectType, enableOrder)
|
||||
super(prisma, objectType, enableOrder);
|
||||
}
|
||||
async getNextOrder(
|
||||
transaction: any,
|
||||
parentId: string | null,
|
||||
parentOrder?: number
|
||||
parentOrder?: number,
|
||||
): Promise<number> {
|
||||
// 查找同层级最后一个节点的 order
|
||||
const lastOrder = await transaction[this.objectType].findFirst({
|
||||
where: {
|
||||
parentId: parentId ?? null
|
||||
parentId: parentId ?? null,
|
||||
},
|
||||
select: { order: true },
|
||||
orderBy: { order: 'desc' },
|
||||
|
@ -41,18 +46,23 @@ export class BaseTreeService<
|
|||
// 如果有父节点
|
||||
if (parentId) {
|
||||
// 获取父节点的 order(如果未提供)
|
||||
const parentNodeOrder = parentOrder ?? (
|
||||
const parentNodeOrder =
|
||||
parentOrder ??
|
||||
(
|
||||
await transaction[this.objectType].findUnique({
|
||||
where: { id: parentId },
|
||||
select: { order: true }
|
||||
select: { order: true },
|
||||
})
|
||||
)?.order ?? 0;
|
||||
)?.order ??
|
||||
0;
|
||||
|
||||
// 如果存在最后一个同层级节点,确保新节点 order 大于最后一个节点
|
||||
// 否则,新节点 order 设置为父节点 order + 1
|
||||
return lastOrder
|
||||
? Math.max(lastOrder.order + this.ORDER_INTERVAL,
|
||||
parentNodeOrder + this.ORDER_INTERVAL)
|
||||
? Math.max(
|
||||
lastOrder.order + this.ORDER_INTERVAL,
|
||||
parentNodeOrder + this.ORDER_INTERVAL,
|
||||
)
|
||||
: parentNodeOrder + this.ORDER_INTERVAL;
|
||||
}
|
||||
|
||||
|
@ -60,28 +70,27 @@ export class BaseTreeService<
|
|||
return lastOrder?.order ? lastOrder?.order + this.ORDER_INTERVAL : 1;
|
||||
}
|
||||
|
||||
async create(args: A['create']) {
|
||||
const anyArgs = args as any
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
async create(args: A['create'], params?: any) {
|
||||
const anyArgs = args as any;
|
||||
// 如果传入了外部事务,直接使用该事务执行所有操作
|
||||
// 如果没有外部事务,则创建新事务
|
||||
const executor = async (transaction: any) => {
|
||||
if (this.enableOrder) {
|
||||
// 获取新节点的 order
|
||||
anyArgs.data.order = await this.getNextOrder(
|
||||
transaction,
|
||||
anyArgs?.data.parentId ?? null
|
||||
anyArgs?.data.parentId ?? null,
|
||||
);
|
||||
}
|
||||
// 创建节点
|
||||
|
||||
const result: any = await super.create(anyArgs, { tx: transaction });
|
||||
|
||||
// 更新父节点的 hasChildren 状态
|
||||
if (anyArgs.data.parentId) {
|
||||
await transaction[this.objectType].update({
|
||||
where: { id: anyArgs.data.parentId },
|
||||
data: { hasChildren: true }
|
||||
data: { hasChildren: true },
|
||||
});
|
||||
}
|
||||
|
||||
// 创建祖先关系
|
||||
const newAncestries = anyArgs.data.parentId
|
||||
? [
|
||||
...(
|
||||
|
@ -105,17 +114,22 @@ export class BaseTreeService<
|
|||
await transaction[this.ancestryType].createMany({ data: newAncestries });
|
||||
|
||||
return result;
|
||||
}) as Promise<R['create']>;
|
||||
};
|
||||
// 根据是否有外部事务决定执行方式
|
||||
if (params?.tx) {
|
||||
return executor(params.tx) as Promise<R['create']>;
|
||||
} else {
|
||||
return this.prisma.$transaction(executor) as Promise<R['create']>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 更新现有单位,并在parentId更改时管理DeptAncestry关系。
|
||||
* @param data - 用于更新现有单位的数据。
|
||||
* @returns 更新后的单位对象。
|
||||
*/
|
||||
async update(args: A['update']) {
|
||||
const anyArgs = args as any
|
||||
async update(args: A['update'], params?: any) {
|
||||
const anyArgs = args as any;
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
const current = await transaction[this.objectType].findUnique({
|
||||
where: { id: anyArgs.where.id },
|
||||
|
@ -132,22 +146,24 @@ export class BaseTreeService<
|
|||
// 更新原父级的 hasChildren 状态
|
||||
if (current.parentId) {
|
||||
const childrenCount = await transaction[this.objectType].count({
|
||||
where: { parentId: current.parentId, deletedAt: null }
|
||||
where: { parentId: current.parentId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (childrenCount === 0) {
|
||||
await transaction[this.objectType].update({
|
||||
where: { id: current.parentId },
|
||||
data: { hasChildren: false }
|
||||
data: { hasChildren: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (anyArgs.data.parentId) {
|
||||
await transaction[this.objectType].update({
|
||||
where: { id: anyArgs.data.parentId },
|
||||
data: { hasChildren: true }
|
||||
data: { hasChildren: true },
|
||||
});
|
||||
const parentAncestries = await transaction[this.ancestryType].findMany({
|
||||
const parentAncestries = await transaction[
|
||||
this.ancestryType
|
||||
].findMany({
|
||||
where: { descendantId: anyArgs.data.parentId },
|
||||
});
|
||||
|
||||
|
@ -165,7 +181,9 @@ export class BaseTreeService<
|
|||
relDepth: 1,
|
||||
});
|
||||
|
||||
await transaction[this.ancestryType].createMany({ data: newAncestries });
|
||||
await transaction[this.ancestryType].createMany({
|
||||
data: newAncestries,
|
||||
});
|
||||
} else {
|
||||
await transaction[this.ancestryType].create({
|
||||
data: { ancestorId: null, descendantId: result.id, relDepth: 0 },
|
||||
|
@ -188,17 +206,17 @@ export class BaseTreeService<
|
|||
ids: string[],
|
||||
data: Partial<DataArgs<A['update']>> = {}, // Default to empty object
|
||||
): Promise<R['update'][]> {
|
||||
return this.prisma.$transaction(async tx => {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// 首先找出所有需要软删除的记录的父级ID
|
||||
const parentIds = await tx[this.objectType].findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
parentId: { not: null }
|
||||
parentId: { not: null },
|
||||
},
|
||||
select: { parentId: true }
|
||||
select: { parentId: true },
|
||||
});
|
||||
|
||||
const uniqueParentIds = [...new Set(parentIds.map(p => p.parentId))];
|
||||
const uniqueParentIds = [...new Set(parentIds.map((p) => p.parentId))];
|
||||
|
||||
// 执行软删除
|
||||
const result = await super.softDeleteByIds(ids, data);
|
||||
|
@ -206,11 +224,8 @@ export class BaseTreeService<
|
|||
// 删除相关的祖先关系
|
||||
await tx[this.ancestryType].deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ancestorId: { in: ids } },
|
||||
{ descendantId: { in: ids } },
|
||||
],
|
||||
}
|
||||
OR: [{ ancestorId: { in: ids } }, { descendantId: { in: ids } }],
|
||||
},
|
||||
});
|
||||
// 更新父级的 hasChildren 状态
|
||||
if (uniqueParentIds.length > 0) {
|
||||
|
@ -218,13 +233,13 @@ export class BaseTreeService<
|
|||
const remainingChildrenCount = await tx[this.objectType].count({
|
||||
where: {
|
||||
parentId: parentId,
|
||||
deletedAt: null
|
||||
}
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
if (remainingChildrenCount === 0) {
|
||||
await tx[this.objectType].update({
|
||||
where: { id: parentId },
|
||||
data: { hasChildren: false }
|
||||
data: { hasChildren: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -234,36 +249,38 @@ export class BaseTreeService<
|
|||
}) as Promise<R['update'][]>;
|
||||
}
|
||||
|
||||
|
||||
getAncestors(ids: string[]) {
|
||||
if (!ids || ids.length === 0) return [];
|
||||
const validIds = ids.filter(id => id != null);
|
||||
const hasNull = ids.includes(null)
|
||||
const validIds = ids.filter((id) => id != null);
|
||||
const hasNull = ids.includes(null);
|
||||
return this.prisma[this.ancestryType].findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ancestorId: { in: validIds } },
|
||||
{ ancestorId: hasNull ? null : undefined },
|
||||
]
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getDescendants(ids: string[]) {
|
||||
if (!ids || ids.length === 0) return [];
|
||||
const validIds = ids.filter(id => id != null);
|
||||
const hasNull = ids.includes(null)
|
||||
const validIds = ids.filter((id) => id != null);
|
||||
const hasNull = ids.includes(null);
|
||||
return this.prisma[this.ancestryType].findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ancestorId: { in: validIds } },
|
||||
{ ancestorId: hasNull ? null : undefined },
|
||||
]
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getDescendantIds(ids: string | string[], includeOriginalIds: boolean = false): Promise<string[]> {
|
||||
async getDescendantIds(
|
||||
ids: string | string[],
|
||||
includeOriginalIds: boolean = false,
|
||||
): Promise<string[]> {
|
||||
// 将单个 ID 转换为数组
|
||||
const idArray = Array.isArray(ids) ? ids : [ids];
|
||||
|
||||
|
@ -271,13 +288,16 @@ export class BaseTreeService<
|
|||
const descendantSet = new Set(res?.map((item) => item.descendantId) || []);
|
||||
|
||||
if (includeOriginalIds) {
|
||||
idArray.forEach(id => descendantSet.add(id));
|
||||
idArray.forEach((id) => descendantSet.add(id));
|
||||
}
|
||||
|
||||
return Array.from(descendantSet).filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
async getAncestorIds(ids: string | string[], includeOriginalIds: boolean = false): Promise<string[]> {
|
||||
async getAncestorIds(
|
||||
ids: string | string[],
|
||||
includeOriginalIds: boolean = false,
|
||||
): Promise<string[]> {
|
||||
// 将单个 ID 转换为数组
|
||||
const idArray = Array.isArray(ids) ? ids : [ids];
|
||||
|
||||
|
@ -303,12 +323,12 @@ export class BaseTreeService<
|
|||
// 查找当前节点和目标节点
|
||||
const currentObject = await transaction[this.objectType].findUnique({
|
||||
where: { id },
|
||||
select: { id: true, parentId: true, order: true }
|
||||
select: { id: true, parentId: true, order: true },
|
||||
});
|
||||
|
||||
const targetObject = await transaction[this.objectType].findUnique({
|
||||
where: { id: overId },
|
||||
select: { id: true, parentId: true, order: true }
|
||||
select: { id: true, parentId: true, order: true },
|
||||
});
|
||||
|
||||
// 验证节点
|
||||
|
@ -320,7 +340,7 @@ export class BaseTreeService<
|
|||
const parentObject = currentObject.parentId
|
||||
? await transaction[this.objectType].findUnique({
|
||||
where: { id: currentObject.parentId },
|
||||
select: { id: true, order: true }
|
||||
select: { id: true, order: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
|
@ -332,46 +352,56 @@ export class BaseTreeService<
|
|||
// 查找同层级的所有节点,按 order 排序
|
||||
const siblingNodes = await transaction[this.objectType].findMany({
|
||||
where: {
|
||||
parentId: targetObject.parentId
|
||||
parentId: targetObject.parentId,
|
||||
},
|
||||
select: { id: true, order: true },
|
||||
orderBy: { order: 'asc' }
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
|
||||
// 找到目标节点和当前节点在兄弟节点中的索引
|
||||
const targetIndex = siblingNodes.findIndex(node => node.id === targetObject.id);
|
||||
const currentIndex = siblingNodes.findIndex(node => node.id === currentObject.id);
|
||||
const targetIndex = siblingNodes.findIndex(
|
||||
(node) => node.id === targetObject.id,
|
||||
);
|
||||
const currentIndex = siblingNodes.findIndex(
|
||||
(node) => node.id === currentObject.id,
|
||||
);
|
||||
|
||||
// 移除当前节点
|
||||
siblingNodes.splice(currentIndex, 1);
|
||||
|
||||
// 在目标位置插入当前节点
|
||||
const insertIndex = currentIndex > targetIndex ? targetIndex + 1 : targetIndex;
|
||||
const insertIndex =
|
||||
currentIndex > targetIndex ? targetIndex + 1 : targetIndex;
|
||||
siblingNodes.splice(insertIndex, 0, currentObject);
|
||||
|
||||
|
||||
// 重新分配 order
|
||||
const newOrders = this.redistributeOrder(siblingNodes, parentObject?.order || 0);
|
||||
const newOrders = this.redistributeOrder(
|
||||
siblingNodes,
|
||||
parentObject?.order || 0,
|
||||
);
|
||||
|
||||
// 批量更新节点的 order
|
||||
const updatePromises = newOrders.map((nodeOrder, index) =>
|
||||
transaction[this.objectType].update({
|
||||
where: { id: siblingNodes[index].id },
|
||||
data: { order: nodeOrder }
|
||||
})
|
||||
data: { order: nodeOrder },
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
// 返回更新后的当前节点
|
||||
return transaction[this.objectType].findUnique({
|
||||
where: { id: currentObject.id }
|
||||
where: { id: currentObject.id },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 重新分配 order 的方法
|
||||
private redistributeOrder(nodes: Array<{ id: string, order: number }>, parentOrder: number): number[] {
|
||||
private redistributeOrder(
|
||||
nodes: Array<{ id: string; order: number }>,
|
||||
parentOrder: number,
|
||||
): number[] {
|
||||
const MIN_CHILD_ORDER = parentOrder + this.ORDER_INTERVAL; // 子节点 order 必须大于父节点
|
||||
const newOrders: number[] = [];
|
||||
|
||||
|
@ -383,7 +413,4 @@ export class BaseTreeService<
|
|||
|
||||
return newOrders;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { CourseRouter } from './course.router';
|
||||
import { CourseService } from './course.service';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
// 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 { }
|
||||
// @Module({
|
||||
// providers: [CourseRouter, CourseService, TrpcService],
|
||||
// exports: [CourseRouter, CourseService]
|
||||
// })
|
||||
// export class CourseModule { }
|
||||
|
|
|
@ -1,94 +1,92 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { Prisma, UpdateOrderSchema } from '@nice/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();
|
||||
// import { Injectable } from '@nestjs/common';
|
||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
||||
// import { Prisma, UpdateOrderSchema } from '@nice/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().optional(),
|
||||
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);
|
||||
}),
|
||||
});
|
||||
}
|
||||
// @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().optional(),
|
||||
// // 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);
|
||||
// // }),
|
||||
// });
|
||||
// }
|
||||
|
|
|
@ -1,78 +1,78 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import {
|
||||
UserProfile,
|
||||
db,
|
||||
ObjectType,
|
||||
Prisma,
|
||||
InstructorRole,
|
||||
} from '@nice/common';
|
||||
@Injectable()
|
||||
export class CourseService extends BaseService<Prisma.CourseDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.COURSE);
|
||||
}
|
||||
async create(
|
||||
args: Prisma.CourseCreateArgs,
|
||||
params?: { staff?: UserProfile }
|
||||
) {
|
||||
return await db.$transaction(async tx => {
|
||||
const result = await super.create(args, { tx });
|
||||
if (params?.staff?.id) {
|
||||
await tx.courseInstructor.create({
|
||||
data: {
|
||||
instructorId: params.staff.id,
|
||||
courseId: result.id,
|
||||
role: InstructorRole.MAIN,
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, {
|
||||
timeout: 10000 // 10 seconds
|
||||
});
|
||||
}
|
||||
async update(
|
||||
args: Prisma.CourseUpdateArgs,
|
||||
params?: { staff?: UserProfile }
|
||||
) {
|
||||
return await db.$transaction(async tx => {
|
||||
const result = await super.update(args, { tx });
|
||||
return result;
|
||||
}, {
|
||||
timeout: 10000 // 10 seconds
|
||||
});
|
||||
}
|
||||
async removeInstructor(courseId: string, instructorId: string) {
|
||||
return await db.courseInstructor.delete({
|
||||
where: {
|
||||
courseId_instructorId: {
|
||||
courseId,
|
||||
instructorId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
async addInstructor(params: {
|
||||
courseId: string;
|
||||
instructorId: string;
|
||||
role?: string;
|
||||
order?: number;
|
||||
}) {
|
||||
return await db.courseInstructor.create({
|
||||
data: {
|
||||
courseId: params.courseId,
|
||||
instructorId: params.instructorId,
|
||||
role: params.role || InstructorRole.ASSISTANT,
|
||||
order: params.order,
|
||||
},
|
||||
});
|
||||
}
|
||||
async getInstructors(courseId: string) {
|
||||
return await db.courseInstructor.findMany({
|
||||
where: { courseId },
|
||||
include: { instructor: true },
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
// import { Injectable } from '@nestjs/common';
|
||||
// import { BaseService } from '../base/base.service';
|
||||
// import {
|
||||
// UserProfile,
|
||||
// db,
|
||||
// ObjectType,
|
||||
// Prisma,
|
||||
// InstructorRole,
|
||||
// } from '@nice/common';
|
||||
// @Injectable()
|
||||
// export class CourseService extends BaseService<Prisma.CourseDelegate> {
|
||||
// constructor() {
|
||||
// super(db, ObjectType.COURSE);
|
||||
// }
|
||||
// async create(
|
||||
// args: Prisma.CourseCreateArgs,
|
||||
// params?: { staff?: UserProfile }
|
||||
// ) {
|
||||
// return await db.$transaction(async tx => {
|
||||
// const result = await super.create(args, { tx });
|
||||
// if (params?.staff?.id) {
|
||||
// await tx.courseInstructor.create({
|
||||
// data: {
|
||||
// instructorId: params.staff.id,
|
||||
// courseId: result.id,
|
||||
// role: InstructorRole.MAIN,
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// return result;
|
||||
// }, {
|
||||
// timeout: 10000 // 10 seconds
|
||||
// });
|
||||
// }
|
||||
// async update(
|
||||
// args: Prisma.CourseUpdateArgs,
|
||||
// params?: { staff?: UserProfile }
|
||||
// ) {
|
||||
// return await db.$transaction(async tx => {
|
||||
// const result = await super.update(args, { tx });
|
||||
// return result;
|
||||
// }, {
|
||||
// timeout: 10000 // 10 seconds
|
||||
// });
|
||||
// }
|
||||
// async removeInstructor(courseId: string, instructorId: string) {
|
||||
// return await db.courseInstructor.delete({
|
||||
// where: {
|
||||
// courseId_instructorId: {
|
||||
// courseId,
|
||||
// instructorId,
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// async addInstructor(params: {
|
||||
// courseId: string;
|
||||
// instructorId: string;
|
||||
// role?: string;
|
||||
// order?: number;
|
||||
// }) {
|
||||
// return await db.courseInstructor.create({
|
||||
// data: {
|
||||
// courseId: params.courseId,
|
||||
// instructorId: params.instructorId,
|
||||
// role: params.role || InstructorRole.ASSISTANT,
|
||||
// order: params.order,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// async getInstructors(courseId: string) {
|
||||
// return await db.courseInstructor.findMany({
|
||||
// where: { courseId },
|
||||
// include: { instructor: true },
|
||||
// orderBy: { order: 'asc' },
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
|
|
@ -1,49 +1,49 @@
|
|||
import { db, EnrollmentStatus, PostType } from '@nice/common';
|
||||
// import { db, EnrollmentStatus, PostType } from '@nice/common';
|
||||
|
||||
// 更新课程评价统计
|
||||
export async function updateCourseReviewStats(courseId: string) {
|
||||
const reviews = await db.post.findMany({
|
||||
where: {
|
||||
courseId,
|
||||
type: PostType.COURSE_REVIEW,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { rating: true },
|
||||
});
|
||||
const numberOfReviews = reviews.length;
|
||||
const averageRating =
|
||||
numberOfReviews > 0
|
||||
? reviews.reduce((sum, review) => sum + review.rating, 0) /
|
||||
numberOfReviews
|
||||
: 0;
|
||||
// // 更新课程评价统计
|
||||
// export async function updateCourseReviewStats(courseId: string) {
|
||||
// const reviews = await db.post.findMany({
|
||||
// where: {
|
||||
// courseId,
|
||||
// type: PostType.COURSE_REVIEW,
|
||||
// deletedAt: null,
|
||||
// },
|
||||
// select: { rating: true },
|
||||
// });
|
||||
// const numberOfReviews = reviews.length;
|
||||
// const averageRating =
|
||||
// numberOfReviews > 0
|
||||
// ? reviews.reduce((sum, review) => sum + review.rating, 0) /
|
||||
// numberOfReviews
|
||||
// : 0;
|
||||
|
||||
return db.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
// numberOfReviews,
|
||||
//averageRating,
|
||||
},
|
||||
});
|
||||
}
|
||||
// return db.course.update({
|
||||
// where: { id: courseId },
|
||||
// data: {
|
||||
// // numberOfReviews,
|
||||
// //averageRating,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
// 更新课程注册统计
|
||||
export async function updateCourseEnrollmentStats(courseId: string) {
|
||||
const completedEnrollments = await db.enrollment.count({
|
||||
where: {
|
||||
courseId,
|
||||
status: EnrollmentStatus.COMPLETED,
|
||||
},
|
||||
});
|
||||
const totalEnrollments = await db.enrollment.count({
|
||||
where: { courseId },
|
||||
});
|
||||
const completionRate =
|
||||
totalEnrollments > 0 ? (completedEnrollments / totalEnrollments) * 100 : 0;
|
||||
return db.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
// numberOfStudents: totalEnrollments,
|
||||
// completionRate,
|
||||
},
|
||||
});
|
||||
}
|
||||
// // 更新课程注册统计
|
||||
// export async function updateCourseEnrollmentStats(courseId: string) {
|
||||
// const completedEnrollments = await db.enrollment.count({
|
||||
// where: {
|
||||
// courseId,
|
||||
// status: EnrollmentStatus.COMPLETED,
|
||||
// },
|
||||
// });
|
||||
// const totalEnrollments = await db.enrollment.count({
|
||||
// where: { courseId },
|
||||
// });
|
||||
// const completionRate =
|
||||
// totalEnrollments > 0 ? (completedEnrollments / totalEnrollments) * 100 : 0;
|
||||
// return db.course.update({
|
||||
// where: { id: courseId },
|
||||
// data: {
|
||||
// // numberOfStudents: totalEnrollments,
|
||||
// // completionRate,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
|
|
@ -37,7 +37,7 @@ export class DepartmentRowService extends RowCacheService {
|
|||
`${this.tableName}.is_domain AS is_domain`, // 是否为域
|
||||
`${this.tableName}.order AS order`, // 排序
|
||||
`${this.tableName}.has_children AS has_children`, // 是否有子部门
|
||||
`${this.tableName}.parent_id AS parent_id` // 父部门 ID
|
||||
`${this.tableName}.parent_id AS parent_id`, // 父部门 ID
|
||||
]);
|
||||
return result;
|
||||
}
|
||||
|
@ -72,7 +72,7 @@ export class DepartmentRowService extends RowCacheService {
|
|||
} else if (parentId === null) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.parent_id`,
|
||||
op: "blank", // 空白操作符
|
||||
op: 'blank', // 空白操作符
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ export class DepartmentService extends BaseTreeService<Prisma.DepartmentDelegate
|
|||
},
|
||||
});
|
||||
// 提取子部门ID列表
|
||||
let descendantDepartmentIds = departmentAncestries.map(
|
||||
const descendantDepartmentIds = departmentAncestries.map(
|
||||
(ancestry) => ancestry.descendantId,
|
||||
);
|
||||
// 根据参数决定是否包含祖先部门ID
|
||||
|
@ -65,7 +65,7 @@ export class DepartmentService extends BaseTreeService<Prisma.DepartmentDelegate
|
|||
});
|
||||
|
||||
// 提取子域的ID列表
|
||||
let descendantDomainIds = domainAncestries.map(
|
||||
const descendantDomainIds = domainAncestries.map(
|
||||
(ancestry) => ancestry.descendantId,
|
||||
);
|
||||
// 根据参数决定是否包含祖先域ID
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
import { UserProfile, db, DeptSimpleTreeNode, TreeDataNode } from "@nice/common";
|
||||
import {
|
||||
UserProfile,
|
||||
db,
|
||||
DeptSimpleTreeNode,
|
||||
TreeDataNode,
|
||||
} from '@nice/common';
|
||||
|
||||
/**
|
||||
* 将部门数据映射为DeptSimpleTreeNode结构
|
||||
|
@ -23,7 +28,7 @@ export function mapToDeptSimpleTree(department: any): DeptSimpleTreeNode {
|
|||
order: department.order,
|
||||
pId: department.parentId,
|
||||
isLeaf: !Boolean(department.children?.length),
|
||||
hasStaff: department?.deptStaffs?.length > 0
|
||||
hasStaff: department?.deptStaffs?.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -40,8 +45,8 @@ export async function getStaffsByDeptIds(ids: string[]) {
|
|||
where: { id: { in: ids } },
|
||||
select: {
|
||||
deptStaffs: {
|
||||
select: { id: true }
|
||||
}
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return depts.flatMap((dept) => dept.deptStaffs);
|
||||
|
@ -57,10 +62,14 @@ export async function getStaffsByDeptIds(ids: string[]) {
|
|||
* - 如果传入了员工信息,则从结果中移除该员工的ID
|
||||
* - 最终返回去重后的员工ID列表
|
||||
*/
|
||||
export async function extractUniqueStaffIds(params: { deptIds?: string[], staffIds?: string[], staff?: UserProfile }): Promise<string[]> {
|
||||
export async function extractUniqueStaffIds(params: {
|
||||
deptIds?: string[];
|
||||
staffIds?: string[];
|
||||
staff?: UserProfile;
|
||||
}): Promise<string[]> {
|
||||
const { deptIds, staff, staffIds } = params;
|
||||
const deptStaffs = await getStaffsByDeptIds(deptIds);
|
||||
const result = new Set(deptStaffs.map(item => item.id).concat(staffIds));
|
||||
const result = new Set(deptStaffs.map((item) => item.id).concat(staffIds));
|
||||
if (staff) {
|
||||
result.delete(staff.id);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { z } from "zod";
|
||||
import { z } from 'zod';
|
||||
|
||||
export const EnrollSchema = z.object({
|
||||
studentId: z.string(),
|
||||
courseId: z.string(),
|
||||
postId: z.string(),
|
||||
});
|
||||
|
||||
export const UnenrollSchema = z.object({
|
||||
studentId: z.string(),
|
||||
courseId: z.string(),
|
||||
postId: z.string(),
|
||||
});
|
|
@ -4,6 +4,6 @@ import { EnrollmentService } from './enrollment.service';
|
|||
|
||||
@Module({
|
||||
exports: [EnrollmentRouter, EnrollmentService],
|
||||
providers: [EnrollmentRouter, EnrollmentService]
|
||||
providers: [EnrollmentRouter, EnrollmentService],
|
||||
})
|
||||
export class EnrollmentModule { }
|
||||
export class EnrollmentModule {}
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import {
|
||||
UserProfile,
|
||||
db,
|
||||
ObjectType,
|
||||
Prisma,
|
||||
EnrollmentStatus
|
||||
EnrollmentStatus,
|
||||
} from '@nice/common';
|
||||
import { z } from 'zod';
|
||||
import { EnrollSchema, UnenrollSchema } from './enroll.schema';
|
||||
|
@ -17,25 +21,25 @@ export class EnrollmentService extends BaseService<Prisma.EnrollmentDelegate> {
|
|||
super(db, ObjectType.COURSE);
|
||||
}
|
||||
async enroll(params: z.infer<typeof EnrollSchema>) {
|
||||
const { studentId, courseId } = params
|
||||
const result = await db.$transaction(async tx => {
|
||||
const { studentId, postId } = params;
|
||||
const result = await db.$transaction(async (tx) => {
|
||||
// 检查是否已经报名
|
||||
const existing = await tx.enrollment.findUnique({
|
||||
where: {
|
||||
studentId_courseId: {
|
||||
studentId_postId: {
|
||||
studentId: studentId,
|
||||
courseId: courseId,
|
||||
postId: postId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('Already enrolled in this course');
|
||||
throw new ConflictException('Already enrolled in this post');
|
||||
}
|
||||
// 创建报名记录
|
||||
const enrollment = await tx.enrollment.create({
|
||||
data: {
|
||||
studentId: studentId,
|
||||
courseId: courseId,
|
||||
postId: postId,
|
||||
status: EnrollmentStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
@ -48,20 +52,20 @@ export class EnrollmentService extends BaseService<Prisma.EnrollmentDelegate> {
|
|||
operation: CrudOperation.CREATED,
|
||||
data: result,
|
||||
});
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
async unenroll(params: z.infer<typeof UnenrollSchema>) {
|
||||
const { studentId, courseId } = params
|
||||
const { studentId, postId } = params;
|
||||
const result = await db.enrollment.update({
|
||||
where: {
|
||||
studentId_courseId: {
|
||||
studentId_postId: {
|
||||
studentId,
|
||||
courseId,
|
||||
postId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: EnrollmentStatus.CANCELLED
|
||||
}
|
||||
status: EnrollmentStatus.CANCELLED,
|
||||
},
|
||||
});
|
||||
|
||||
EventBus.emit('dataChanged', {
|
||||
|
@ -69,6 +73,6 @@ export class EnrollmentService extends BaseService<Prisma.EnrollmentDelegate> {
|
|||
operation: CrudOperation.UPDATED,
|
||||
data: result,
|
||||
});
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { LectureRouter } from './lecture.router';
|
||||
import { LectureService } from './lecture.service';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
// 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 { }
|
||||
// @Module({
|
||||
// providers: [LectureRouter, LectureService, TrpcService],
|
||||
// exports: [LectureRouter, LectureService]
|
||||
// })
|
||||
// export class LectureModule { }
|
||||
|
|
|
@ -1,70 +1,70 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { Prisma, UpdateOrderSchema } from '@nice/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()
|
||||
// import { Injectable } from '@nestjs/common';
|
||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
||||
// import { Prisma, UpdateOrderSchema } from '@nice/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;
|
||||
// @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);
|
||||
}),
|
||||
});
|
||||
}
|
||||
// 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);
|
||||
// }),
|
||||
// });
|
||||
// }
|
||||
|
|
|
@ -1,35 +1,35 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import {
|
||||
UserProfile,
|
||||
db,
|
||||
ObjectType,
|
||||
Prisma
|
||||
} from '@nice/common';
|
||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
// import { Injectable } from '@nestjs/common';
|
||||
// import { BaseService } from '../base/base.service';
|
||||
// import {
|
||||
// UserProfile,
|
||||
// db,
|
||||
// ObjectType,
|
||||
// Prisma
|
||||
// } from '@nice/common';
|
||||
// import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
|
||||
@Injectable()
|
||||
export class LectureService extends BaseService<Prisma.LectureDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.COURSE);
|
||||
}
|
||||
async create(args: Prisma.LectureCreateArgs, params?: { staff?: UserProfile }) {
|
||||
const result = await super.create(args)
|
||||
EventBus.emit('dataChanged', {
|
||||
type: ObjectType.LECTURE,
|
||||
operation: CrudOperation.CREATED,
|
||||
data: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
async update(args: Prisma.LectureUpdateArgs) {
|
||||
const result = await super.update(args);
|
||||
EventBus.emit('dataChanged', {
|
||||
type: ObjectType.LECTURE,
|
||||
operation: CrudOperation.UPDATED,
|
||||
data: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
// @Injectable()
|
||||
// export class LectureService extends BaseService<Prisma.LectureDelegate> {
|
||||
// constructor() {
|
||||
// super(db, ObjectType.COURSE);
|
||||
// }
|
||||
// async create(args: Prisma.LectureCreateArgs, params?: { staff?: UserProfile }) {
|
||||
// const result = await super.create(args)
|
||||
// EventBus.emit('dataChanged', {
|
||||
// type: ObjectType.LECTURE,
|
||||
// operation: CrudOperation.CREATED,
|
||||
// data: result,
|
||||
// });
|
||||
// return result;
|
||||
// }
|
||||
// async update(args: Prisma.LectureUpdateArgs) {
|
||||
// const result = await super.update(args);
|
||||
// EventBus.emit('dataChanged', {
|
||||
// type: ObjectType.LECTURE,
|
||||
// operation: CrudOperation.UPDATED,
|
||||
// data: result,
|
||||
// });
|
||||
// return result;
|
||||
// }
|
||||
|
||||
}
|
||||
// }
|
||||
|
|
|
@ -1,39 +1,48 @@
|
|||
import { db, Lecture } from '@nice/common';
|
||||
import { db, PostType } from '@nice/common';
|
||||
|
||||
export async function updateSectionLectureStats(sectionId: string) {
|
||||
const sectionStats = await db.lecture.aggregate({
|
||||
where: {
|
||||
sectionId,
|
||||
deletedAt: null,
|
||||
},
|
||||
_count: { _all: true },
|
||||
_sum: { duration: true },
|
||||
});
|
||||
// export async function updateSectionLectureStats(sectionId: string) {
|
||||
// const sectionStats = await db.post.aggregate({
|
||||
// where: {
|
||||
// parentId: sectionId,
|
||||
// deletedAt: null,
|
||||
// type: PostType.LECTURE,
|
||||
// },
|
||||
// _count: { _all: true },
|
||||
// _sum: { duration: true },
|
||||
// });
|
||||
|
||||
await db.section.update({
|
||||
where: { id: sectionId },
|
||||
data: {
|
||||
// totalLectures: sectionStats._count._all,
|
||||
// totalDuration: sectionStats._sum.duration || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
// await db.post.update({
|
||||
// where: { id: sectionId },
|
||||
// data: {
|
||||
// // totalLectures: sectionStats._count._all,
|
||||
// // totalDuration: sectionStats._sum.duration || 0,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
export async function updateCourseLectureStats(courseId: string) {
|
||||
const courseStats = await db.lecture.aggregate({
|
||||
where: {
|
||||
courseId,
|
||||
deletedAt: null,
|
||||
},
|
||||
_count: { _all: true },
|
||||
_sum: { duration: true },
|
||||
});
|
||||
|
||||
await db.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
//totalLectures: courseStats._count._all,
|
||||
//totalDuration: courseStats._sum.duration || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
// export async function updateParentLectureStats(parentId: string) {
|
||||
// const ParentStats = await db.post.aggregate({
|
||||
// where: {
|
||||
// ancestors: {
|
||||
// some: {
|
||||
// ancestorId: parentId,
|
||||
// descendant: {
|
||||
// type: PostType.LECTURE,
|
||||
// deletedAt: null,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// _count: { _all: true },
|
||||
// _sum: {
|
||||
// duration: true,
|
||||
// },
|
||||
// });
|
||||
// await db.post.update({
|
||||
// where: { id: parentId },
|
||||
// data: {
|
||||
// //totalLectures: courseStats._count._all,
|
||||
// //totalDuration: courseStats._sum.duration || 0,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { Prisma } from '@nice/common';
|
||||
import { CourseMethodSchema, Prisma } from '@nice/common';
|
||||
import { PostService } from './post.service';
|
||||
import { z, ZodType } from 'zod';
|
||||
const PostCreateArgsSchema: ZodType<Prisma.PostCreateArgs> = z.any();
|
||||
const PostUpdateArgsSchema: ZodType<Prisma.PostUpdateArgs> = z.any();
|
||||
const PostFindFirstArgsSchema: ZodType<Prisma.PostFindFirstArgs> = z.any();
|
||||
const PostFindManyArgsSchema: ZodType<Prisma.PostFindManyArgs> = z.any();
|
||||
const PostDeleteManyArgsSchema: ZodType<Prisma.PostDeleteManyArgs> = z.any();
|
||||
const PostWhereInputSchema: ZodType<Prisma.PostWhereInput> = z.any();
|
||||
const PostSelectSchema: ZodType<Prisma.PostSelect> = z.any();
|
||||
|
@ -15,7 +16,7 @@ export class PostRouter {
|
|||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly postService: PostService,
|
||||
) { }
|
||||
) {}
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(PostCreateArgsSchema)
|
||||
|
@ -23,6 +24,12 @@ export class PostRouter {
|
|||
const { staff } = ctx;
|
||||
return await this.postService.create(input, { staff });
|
||||
}),
|
||||
createCourse: this.trpc.protectProcedure
|
||||
.input(CourseMethodSchema.createCourse)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.createCourse(input, { staff });
|
||||
}),
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
@ -55,6 +62,21 @@ export class PostRouter {
|
|||
const { staff } = ctx;
|
||||
return await this.postService.findById(input.id, input.args);
|
||||
}),
|
||||
findMany: this.trpc.protectProcedure
|
||||
.input(PostFindManyArgsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.findMany(input);
|
||||
}),
|
||||
|
||||
findFirst: this.trpc.procedure
|
||||
.input(PostFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { staff, ip } = ctx;
|
||||
// 从请求中获取 IP
|
||||
|
||||
return await this.postService.findFirst(input, staff, ip);
|
||||
}),
|
||||
deleteMany: this.trpc.protectProcedure
|
||||
.input(PostDeleteManyArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
|
@ -73,5 +95,17 @@ export class PostRouter {
|
|||
const { staff } = ctx;
|
||||
return await this.postService.findManyWithCursor(input, staff);
|
||||
}),
|
||||
findManyWithPagination: this.trpc.procedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().optional(),
|
||||
pageSize: z.number().optional(),
|
||||
where: PostWhereInputSchema.optional(),
|
||||
select: PostSelectSchema.optional(),
|
||||
}),
|
||||
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.postService.findManyWithPagination(input);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -9,24 +9,146 @@ import {
|
|||
RolePerms,
|
||||
ResPerm,
|
||||
ObjectType,
|
||||
LectureType,
|
||||
CourseMethodSchema,
|
||||
} from '@nice/common';
|
||||
import { MessageService } from '../message/message.service';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import { DepartmentService } from '../department/department.service';
|
||||
import { setPostRelation } from './utils';
|
||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
import { BaseTreeService } from '../base/base.tree.service';
|
||||
import { z } from 'zod';
|
||||
|
||||
@Injectable()
|
||||
export class PostService extends BaseService<Prisma.PostDelegate> {
|
||||
export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||
constructor(
|
||||
private readonly messageService: MessageService,
|
||||
private readonly departmentService: DepartmentService,
|
||||
) {
|
||||
super(db, ObjectType.POST);
|
||||
super(db, ObjectType.POST, 'postAncestry', true);
|
||||
}
|
||||
async createLecture(
|
||||
lecture: z.infer<typeof CourseMethodSchema.createLecture>,
|
||||
params: { staff?: UserProfile; tx?: Prisma.TransactionClient },
|
||||
) {
|
||||
const { sectionId, type, title, content, resourceIds = [] } = lecture;
|
||||
const { staff, tx } = params;
|
||||
return await this.create(
|
||||
{
|
||||
data: {
|
||||
type: PostType.LECTURE,
|
||||
parentId: sectionId,
|
||||
content: content,
|
||||
title: title,
|
||||
authorId: params?.staff?.id,
|
||||
resources: {
|
||||
connect: resourceIds.map((fileId) => ({ fileId })),
|
||||
},
|
||||
meta: {
|
||||
type: type,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ tx },
|
||||
);
|
||||
}
|
||||
async createSection(
|
||||
section: z.infer<typeof CourseMethodSchema.createSection>,
|
||||
params: {
|
||||
staff?: UserProfile;
|
||||
tx?: Prisma.TransactionClient;
|
||||
},
|
||||
) {
|
||||
const { title, courseId, lectures } = section;
|
||||
const { staff, tx } = params;
|
||||
// Create section first
|
||||
const createdSection = await this.create(
|
||||
{
|
||||
data: {
|
||||
type: PostType.SECTION,
|
||||
parentId: courseId,
|
||||
title: title,
|
||||
authorId: staff?.id,
|
||||
},
|
||||
},
|
||||
{ tx },
|
||||
);
|
||||
// If lectures are provided, create them
|
||||
if (lectures && lectures.length > 0) {
|
||||
const lecturePromises = lectures.map((lecture) =>
|
||||
this.createLecture(
|
||||
{
|
||||
sectionId: createdSection.id,
|
||||
...lecture,
|
||||
},
|
||||
params,
|
||||
),
|
||||
);
|
||||
|
||||
// Create all lectures in parallel
|
||||
await Promise.all(lecturePromises);
|
||||
}
|
||||
return createdSection;
|
||||
}
|
||||
async createCourse(
|
||||
args: {
|
||||
courseDetail: Prisma.PostCreateArgs;
|
||||
sections?: z.infer<typeof CourseMethodSchema.createSection>[];
|
||||
},
|
||||
params: { staff?: UserProfile; tx?: Prisma.TransactionClient },
|
||||
) {
|
||||
const { courseDetail, sections } = args;
|
||||
// If no transaction is provided, create a new one
|
||||
if (!params.tx) {
|
||||
return await db.$transaction(async (tx) => {
|
||||
const courseParams = { ...params, tx };
|
||||
// Create the course first
|
||||
console.log(courseParams?.staff?.id);
|
||||
console.log('courseDetail', courseDetail);
|
||||
const createdCourse = await this.create(courseDetail, courseParams);
|
||||
// If sections are provided, create them
|
||||
if (sections && sections.length > 0) {
|
||||
const sectionPromises = sections.map((section) =>
|
||||
this.createSection(
|
||||
{
|
||||
courseId: createdCourse.id,
|
||||
title: section.title,
|
||||
lectures: section.lectures,
|
||||
},
|
||||
courseParams,
|
||||
),
|
||||
);
|
||||
// Create all sections (and their lectures) in parallel
|
||||
await Promise.all(sectionPromises);
|
||||
}
|
||||
return createdCourse;
|
||||
});
|
||||
}
|
||||
// If transaction is provided, use it directly
|
||||
console.log('courseDetail', courseDetail);
|
||||
const createdCourse = await this.create(courseDetail, params);
|
||||
// If sections are provided, create them
|
||||
if (sections && sections.length > 0) {
|
||||
const sectionPromises = sections.map((section) =>
|
||||
this.createSection(
|
||||
{
|
||||
courseId: createdCourse.id,
|
||||
title: section.title,
|
||||
lectures: section.lectures,
|
||||
},
|
||||
params,
|
||||
),
|
||||
);
|
||||
// Create all sections (and their lectures) in parallel
|
||||
await Promise.all(sectionPromises);
|
||||
}
|
||||
|
||||
return createdCourse;
|
||||
}
|
||||
async create(
|
||||
args: Prisma.PostCreateArgs,
|
||||
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
|
||||
params?: { staff?: UserProfile; tx?: Prisma.TransactionClient },
|
||||
) {
|
||||
args.data.authorId = params?.staff?.id;
|
||||
const result = await super.create(args);
|
||||
|
@ -45,13 +167,44 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
|
|||
operation: CrudOperation.UPDATED,
|
||||
data: result,
|
||||
});
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
async findFirst(
|
||||
args?: Prisma.PostFindFirstArgs,
|
||||
staff?: UserProfile,
|
||||
clientIp?: string,
|
||||
) {
|
||||
const transDto = await this.wrapResult(
|
||||
super.findFirst(args),
|
||||
async (result) => {
|
||||
if (result) {
|
||||
await setPostRelation({ data: result, staff });
|
||||
await this.setPerms(result, staff);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
);
|
||||
return transDto;
|
||||
}
|
||||
// async findMany(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
|
||||
// if (!args.where) args.where = {};
|
||||
// args.where.OR = await this.preFilter(args.where.OR, staff);
|
||||
// return this.wrapResult(super.findMany(args), async (result) => {
|
||||
// await Promise.all(
|
||||
// result.map(async (item) => {
|
||||
// await setPostRelation({ data: item, staff });
|
||||
// await this.setPerms(item, staff);
|
||||
// }),
|
||||
// );
|
||||
// return { ...result };
|
||||
// });
|
||||
// }
|
||||
async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
|
||||
if (!args.where) args.where = {}
|
||||
if (!args.where) args.where = {};
|
||||
args.where.OR = await this.preFilter(args.where.OR, staff);
|
||||
return this.wrapResult(super.findManyWithCursor(args), async (result) => {
|
||||
let { items } = result;
|
||||
const { items } = result;
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
await setPostRelation({ data: item, staff });
|
||||
|
@ -99,7 +252,7 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
|
|||
return outOR?.length > 0 ? outOR : undefined;
|
||||
}
|
||||
async getPostPreFilter(staff?: UserProfile) {
|
||||
if (!staff) return
|
||||
if (!staff) return;
|
||||
const { deptId, domainId } = staff;
|
||||
if (
|
||||
staff.permissions.includes(RolePerms.READ_ANY_POST) ||
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
import { db, Post, PostType, UserProfile, VisitType } from "@nice/common";
|
||||
import {
|
||||
db,
|
||||
EnrollmentStatus,
|
||||
Post,
|
||||
PostType,
|
||||
UserProfile,
|
||||
VisitType,
|
||||
} from '@nice/common';
|
||||
|
||||
export async function setPostRelation(params: { data: Post, staff?: UserProfile }) {
|
||||
const { data, staff } = params
|
||||
export async function setPostRelation(params: {
|
||||
data: Post;
|
||||
staff?: UserProfile;
|
||||
}) {
|
||||
const { data, staff } = params;
|
||||
const limitedComments = await db.post.findMany({
|
||||
where: {
|
||||
parentId: data.id,
|
||||
|
@ -39,6 +49,77 @@ export async function setPostRelation(params: { data: Post, staff?: UserProfile
|
|||
limitedComments,
|
||||
commentsCount,
|
||||
// trouble
|
||||
})
|
||||
|
||||
});
|
||||
}
|
||||
export async function updateParentLectureStats(parentId: string) {
|
||||
const ParentStats = await db.post.aggregate({
|
||||
where: {
|
||||
ancestors: {
|
||||
some: {
|
||||
ancestorId: parentId,
|
||||
descendant: {
|
||||
type: PostType.LECTURE,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
_sum: {
|
||||
duration: true,
|
||||
},
|
||||
});
|
||||
await db.post.update({
|
||||
where: { id: parentId },
|
||||
data: {
|
||||
//totalLectures: courseStats._count._all,
|
||||
//totalDuration: courseStats._sum.duration || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 更新课程评价统计
|
||||
export async function updateCourseReviewStats(courseId: string) {
|
||||
const reviews = await db.visit.findMany({
|
||||
where: {
|
||||
postId: courseId,
|
||||
type: PostType.COURSE_REVIEW,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { views: true },
|
||||
});
|
||||
const numberOfReviews = reviews.length;
|
||||
const averageRating =
|
||||
numberOfReviews > 0
|
||||
? reviews.reduce((sum, review) => sum + review.views, 0) / numberOfReviews
|
||||
: 0;
|
||||
|
||||
return db.post.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
// numberOfReviews,
|
||||
//averageRating,
|
||||
},
|
||||
});
|
||||
}
|
||||
// 更新课程注册统计
|
||||
export async function updateCourseEnrollmentStats(courseId: string) {
|
||||
const completedEnrollments = await db.enrollment.count({
|
||||
where: {
|
||||
postId: courseId,
|
||||
status: EnrollmentStatus.COMPLETED,
|
||||
},
|
||||
});
|
||||
const totalEnrollments = await db.enrollment.count({
|
||||
where: { postId: courseId },
|
||||
});
|
||||
const completionRate =
|
||||
totalEnrollments > 0 ? (completedEnrollments / totalEnrollments) * 100 : 0;
|
||||
return db.post.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
// numberOfStudents: totalEnrollments,
|
||||
// completionRate,
|
||||
},
|
||||
});
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { db, RoleMethodSchema, ObjectType, Prisma } from "@nice/common";
|
||||
import { db, RoleMethodSchema, ObjectType, Prisma } from '@nice/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
@Injectable()
|
||||
export class RoleService extends BaseService<Prisma.RoleDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.ROLE)
|
||||
super(db, ObjectType.ROLE);
|
||||
}
|
||||
/**
|
||||
* 批量删除角色
|
||||
|
@ -16,11 +16,10 @@ export class RoleService extends BaseService<Prisma.RoleDelegate> {
|
|||
await db.roleMap.deleteMany({
|
||||
where: {
|
||||
roleId: {
|
||||
in: ids
|
||||
}
|
||||
}
|
||||
in: ids,
|
||||
},
|
||||
},
|
||||
});
|
||||
return await super.softDeleteByIds(ids, data)
|
||||
return await super.softDeleteByIds(ids, data);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -51,7 +51,7 @@ export class ResourceProcessingPipeline {
|
|||
ResourceStatus.PROCESSED,
|
||||
);
|
||||
this.logger.log(
|
||||
`资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.metadata)}`,
|
||||
`资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.meta)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -13,7 +13,7 @@ export class ImageProcessor extends BaseProcessor {
|
|||
async process(resource: Resource): Promise<Resource> {
|
||||
const { url } = resource;
|
||||
const filepath = getUploadFilePath(url);
|
||||
const originMeta = resource.metadata as unknown as FileMetadata;
|
||||
const originMeta = resource.meta as unknown as FileMetadata;
|
||||
if (!originMeta.mimeType?.startsWith('image/')) {
|
||||
this.logger.log(`Skipping non-image resource: ${resource.id}`);
|
||||
return resource;
|
||||
|
@ -47,7 +47,7 @@ export class ImageProcessor extends BaseProcessor {
|
|||
const updatedResource = await db.resource.update({
|
||||
where: { id: resource.id },
|
||||
data: {
|
||||
metadata: {
|
||||
meta: {
|
||||
...originMeta,
|
||||
...imageMeta,
|
||||
},
|
||||
|
|
|
@ -18,7 +18,7 @@ export class VideoProcessor extends BaseProcessor {
|
|||
`Processing video for resource ID: ${resource.id}, File ID: ${url}`,
|
||||
);
|
||||
|
||||
const originMeta = resource.metadata as unknown as FileMetadata;
|
||||
const originMeta = resource.meta as unknown as FileMetadata;
|
||||
if (!originMeta.mimeType?.startsWith('video/')) {
|
||||
this.logger.log(`Skipping non-video resource: ${resource.id}`);
|
||||
return resource;
|
||||
|
@ -39,7 +39,7 @@ export class VideoProcessor extends BaseProcessor {
|
|||
const updatedResource = await db.resource.update({
|
||||
where: { id: resource.id },
|
||||
data: {
|
||||
metadata: {
|
||||
meta: {
|
||||
...originMeta,
|
||||
...videoMeta,
|
||||
},
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SectionRouter } from './section.router';
|
||||
import { SectionService } from './section.service';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
// 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 { }
|
||||
// @Module({
|
||||
// exports: [SectionRouter, SectionService],
|
||||
// providers: [SectionRouter, SectionService, TrpcService]
|
||||
// })
|
||||
// export class SectionModule { }
|
||||
|
|
|
@ -1,70 +1,70 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { Prisma, UpdateOrderSchema } from '@nice/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()
|
||||
// import { Injectable } from '@nestjs/common';
|
||||
// import { TrpcService } from '@server/trpc/trpc.service';
|
||||
// import { Prisma, UpdateOrderSchema } from '@nice/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;
|
||||
// @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);
|
||||
}),
|
||||
});
|
||||
}
|
||||
// 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);
|
||||
// }),
|
||||
// });
|
||||
// }
|
||||
|
|
|
@ -1,24 +1,23 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import {
|
||||
UserProfile,
|
||||
db,
|
||||
ObjectType,
|
||||
Prisma,
|
||||
// import { Injectable } from '@nestjs/common';
|
||||
// import { BaseService } from '../base/base.service';
|
||||
// import {
|
||||
// UserProfile,
|
||||
// db,
|
||||
// ObjectType,
|
||||
// Prisma,
|
||||
|
||||
} from '@nice/common';
|
||||
@Injectable()
|
||||
export class SectionService extends BaseService<Prisma.SectionDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.SECTION);
|
||||
}
|
||||
// } from '@nice/common';
|
||||
// @Injectable()
|
||||
// export class SectionService extends BaseService<Prisma.SectionDelegate> {
|
||||
// constructor() {
|
||||
// super(db, ObjectType.SECTION);
|
||||
// }
|
||||
|
||||
create(args: Prisma.SectionCreateArgs, params?: { staff?: UserProfile }) {
|
||||
return super.create(args)
|
||||
}
|
||||
async update(args: Prisma.SectionUpdateArgs) {
|
||||
return super.update(args);
|
||||
}
|
||||
// create(args: Prisma.SectionCreateArgs, params?: { staff?: UserProfile }) {
|
||||
// return super.create(args)
|
||||
// }
|
||||
// async update(args: Prisma.SectionUpdateArgs) {
|
||||
// return super.update(args);
|
||||
// }
|
||||
|
||||
|
||||
}
|
||||
// }
|
||||
|
|
|
@ -10,15 +10,15 @@ const StaffFindFirstArgsSchema: ZodType<Prisma.StaffFindFirstArgs> = z.any();
|
|||
const StaffDeleteManyArgsSchema: ZodType<Prisma.StaffDeleteManyArgs> = z.any();
|
||||
const StaffWhereInputSchema: ZodType<Prisma.StaffWhereInput> = z.any();
|
||||
const StaffSelectSchema: ZodType<Prisma.StaffSelect> = z.any();
|
||||
const StaffUpdateInputSchema: ZodType<Prisma.PostUpdateInput> = z.any();
|
||||
const StaffUpdateInputSchema: ZodType<Prisma.StaffUpdateInput> = z.any();
|
||||
const StaffFindManyArgsSchema: ZodType<Prisma.StaffFindManyArgs> = z.any();
|
||||
@Injectable()
|
||||
export class StaffRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly staffService: StaffService,
|
||||
private readonly staffRowService: StaffRowService
|
||||
) { }
|
||||
private readonly staffRowService: StaffRowService,
|
||||
) {}
|
||||
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.procedure
|
||||
|
@ -35,7 +35,7 @@ export class StaffRouter {
|
|||
updateUserDomain: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
domainId: z.string()
|
||||
domainId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
@ -72,8 +72,10 @@ export class StaffRouter {
|
|||
const { staff } = ctx;
|
||||
return await this.staffService.findFirst(input);
|
||||
}),
|
||||
updateOrder: this.trpc.protectProcedure.input(UpdateOrderSchema).mutation(async ({ input }) => {
|
||||
return this.staffService.updateOrder(input)
|
||||
updateOrder: this.trpc.protectProcedure
|
||||
.input(UpdateOrderSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.staffService.updateOrder(input);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -7,13 +7,13 @@ import { z } from 'zod';
|
|||
|
||||
@Injectable()
|
||||
export class TaxonomyService {
|
||||
constructor() { }
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* 清除分页缓存,删除所有以'taxonomies:page:'开头的键
|
||||
*/
|
||||
private async invalidatePaginationCache() {
|
||||
deleteByPattern('taxonomies:page:*')
|
||||
deleteByPattern('taxonomies:page:*');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -42,7 +42,7 @@ export class TaxonomyService {
|
|||
async findByName(input: z.infer<typeof TaxonomyMethodSchema.findByName>) {
|
||||
const { name } = input;
|
||||
const cacheKey = `taxonomy:${name}`;
|
||||
let cachedTaxonomy = await redis.get(cacheKey);
|
||||
const cachedTaxonomy = await redis.get(cacheKey);
|
||||
if (cachedTaxonomy) {
|
||||
return JSON.parse(cachedTaxonomy);
|
||||
}
|
||||
|
@ -55,7 +55,7 @@ export class TaxonomyService {
|
|||
async findBySlug(input: z.infer<typeof TaxonomyMethodSchema.findBySlug>) {
|
||||
const { slug } = input;
|
||||
const cacheKey = `taxonomy-slug:${slug}`;
|
||||
let cachedTaxonomy = await redis.get(cacheKey);
|
||||
const cachedTaxonomy = await redis.get(cacheKey);
|
||||
if (cachedTaxonomy) {
|
||||
return JSON.parse(cachedTaxonomy);
|
||||
}
|
||||
|
@ -72,7 +72,7 @@ export class TaxonomyService {
|
|||
*/
|
||||
async findById(input: z.infer<typeof TaxonomyMethodSchema.findById>) {
|
||||
const cacheKey = `taxonomy:${input.id}`;
|
||||
let cachedTaxonomy = await redis.get(cacheKey);
|
||||
const cachedTaxonomy = await redis.get(cacheKey);
|
||||
if (cachedTaxonomy) {
|
||||
return JSON.parse(cachedTaxonomy);
|
||||
}
|
||||
|
@ -149,7 +149,7 @@ export class TaxonomyService {
|
|||
*/
|
||||
async paginate(input: any) {
|
||||
const cacheKey = `taxonomies:page:${input.page}:size:${input.pageSize}`;
|
||||
let cachedData = await redis.get(cacheKey);
|
||||
const cachedData = await redis.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return JSON.parse(cachedData);
|
||||
}
|
||||
|
@ -197,7 +197,7 @@ export class TaxonomyService {
|
|||
slug: true,
|
||||
objectType: true,
|
||||
order: true,
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,14 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TransformService } from './transform.service';
|
||||
import { TransformMethodSchema} from '@nice/common';
|
||||
import { TransformMethodSchema } from '@nice/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
@Injectable()
|
||||
export class TransformRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly transformService: TransformService,
|
||||
) { }
|
||||
) {}
|
||||
router = this.trpc.router({
|
||||
|
||||
|
||||
importTerms: this.trpc.protectProcedure
|
||||
.input(TransformMethodSchema.importTerms) // expect input according to the schema
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
@ -29,6 +27,5 @@ export class TransformRouter {
|
|||
const { staff } = ctx;
|
||||
return this.transformService.importStaffs(input);
|
||||
}),
|
||||
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import {
|
||||
TransformMethodSchema,
|
||||
db,
|
||||
Prisma,
|
||||
Staff,
|
||||
} from '@nice/common';
|
||||
import { TransformMethodSchema, db, Prisma, Staff } from '@nice/common';
|
||||
import dayjs from 'dayjs';
|
||||
import * as argon2 from 'argon2';
|
||||
import { TaxonomyService } from '@server/models/taxonomy/taxonomy.service';
|
||||
|
@ -543,5 +538,4 @@ export class TransformService {
|
|||
// },
|
||||
// });
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,12 +1,6 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import {
|
||||
UserProfile,
|
||||
db,
|
||||
ObjectType,
|
||||
Prisma,
|
||||
VisitType,
|
||||
} from '@nice/common';
|
||||
import { UserProfile, db, ObjectType, Prisma, VisitType } from '@nice/common';
|
||||
import EventBus from '@server/utils/event-bus';
|
||||
@Injectable()
|
||||
export class VisitService extends BaseService<Prisma.VisitDelegate> {
|
||||
|
@ -50,7 +44,7 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
|
|||
const createData: Prisma.VisitCreateManyInput[] = [];
|
||||
await Promise.all(
|
||||
data.map(async (item) => {
|
||||
if (staff && !item.visitorId) item.visitorId = staff.id
|
||||
if (staff && !item.visitorId) item.visitorId = staff.id;
|
||||
const { postId, lectureId, messageId, visitorId } = item;
|
||||
const existingVisit = await db.visit.findFirst({
|
||||
where: {
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
import { Job } from 'bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
updateCourseLectureStats,
|
||||
updateSectionLectureStats,
|
||||
} from '@server/models/lecture/utils';
|
||||
import { ObjectType } from '@nice/common';
|
||||
// import {
|
||||
// updateCourseEnrollmentStats,
|
||||
// updateCourseReviewStats,
|
||||
// } from '@server/models/course/utils';
|
||||
import { QueueJobType } from '../types';
|
||||
import {
|
||||
updateCourseEnrollmentStats,
|
||||
updateCourseReviewStats,
|
||||
} from '@server/models/course/utils';
|
||||
import { QueueJobType } from '../types';
|
||||
updateParentLectureStats,
|
||||
} from '@server/models/post/utils';
|
||||
const logger = new Logger('QueueWorker');
|
||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||
try {
|
||||
|
@ -17,7 +18,7 @@ export default async function processJob(job: Job<any, any, QueueJobType>) {
|
|||
const { sectionId, courseId, type } = job.data;
|
||||
// 处理 section 统计
|
||||
if (sectionId) {
|
||||
await updateSectionLectureStats(sectionId);
|
||||
await updateParentLectureStats(sectionId);
|
||||
logger.debug(`Updated section stats for sectionId: ${sectionId}`);
|
||||
}
|
||||
// 如果没有 courseId,提前返回
|
||||
|
@ -27,7 +28,7 @@ export default async function processJob(job: Job<any, any, QueueJobType>) {
|
|||
// 处理 course 相关统计
|
||||
switch (type) {
|
||||
case ObjectType.LECTURE:
|
||||
await updateCourseLectureStats(courseId);
|
||||
await updateParentLectureStats(courseId);
|
||||
break;
|
||||
case ObjectType.ENROLLMENT:
|
||||
await updateCourseEnrollmentStats(courseId);
|
||||
|
|
|
@ -12,12 +12,7 @@ import {
|
|||
Term,
|
||||
} from '@nice/common';
|
||||
import EventBus from '@server/utils/event-bus';
|
||||
import {
|
||||
|
||||
capitalizeFirstLetter,
|
||||
DevDataCounts,
|
||||
getCounts,
|
||||
} from './utils';
|
||||
import { capitalizeFirstLetter, DevDataCounts, getCounts } from './utils';
|
||||
import { StaffService } from '@server/models/staff/staff.service';
|
||||
@Injectable()
|
||||
export class GenDevService {
|
||||
|
@ -28,6 +23,7 @@ export class GenDevService {
|
|||
[TaxonomySlug.CATEGORY]: [],
|
||||
[TaxonomySlug.UNIT]: [],
|
||||
[TaxonomySlug.TAG]: [],
|
||||
[TaxonomySlug.LEVEL]: [],
|
||||
};
|
||||
depts: Department[] = [];
|
||||
domains: Department[] = [];
|
||||
|
@ -40,7 +36,7 @@ export class GenDevService {
|
|||
private readonly departmentService: DepartmentService,
|
||||
private readonly staffService: StaffService,
|
||||
private readonly termService: TermService,
|
||||
) { }
|
||||
) {}
|
||||
async genDataEvent() {
|
||||
EventBus.emit('genDataEvent', { type: 'start' });
|
||||
try {
|
||||
|
@ -48,7 +44,6 @@ export class GenDevService {
|
|||
await this.generateDepartments(3, 6);
|
||||
await this.generateTerms(2, 6);
|
||||
await this.generateStaffs(4);
|
||||
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
|
@ -165,8 +160,8 @@ export class GenDevService {
|
|||
showname: username,
|
||||
username: username,
|
||||
deptId: dept.id,
|
||||
domainId: domain.id
|
||||
}
|
||||
domainId: domain.id,
|
||||
},
|
||||
});
|
||||
// Update both deptStaffRecord and staffs array
|
||||
this.deptStaffRecord[dept.id].push(staff);
|
||||
|
@ -191,7 +186,7 @@ export class GenDevService {
|
|||
name,
|
||||
isDomain: currentDepth === 1 ? true : false,
|
||||
parentId,
|
||||
}
|
||||
},
|
||||
});
|
||||
return department;
|
||||
}
|
||||
|
@ -218,7 +213,7 @@ export class GenDevService {
|
|||
taxonomyId: taxonomy!.id,
|
||||
domainId: domain?.id,
|
||||
parentId,
|
||||
}
|
||||
},
|
||||
});
|
||||
this.terms[taxonomySlug].push(newTerm);
|
||||
await createTermTree(newTerm.id, currentDepth + 1);
|
||||
|
|
|
@ -14,9 +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';
|
||||
|
||||
import { ResourceModule } from '@server/models/resource/resource.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
|
@ -31,12 +31,10 @@ import { SectionModule } from '@server/models/section/section.module';
|
|||
AppConfigModule,
|
||||
PostModule,
|
||||
VisitModule,
|
||||
CourseModule,
|
||||
LectureModule,
|
||||
SectionModule,
|
||||
WebSocketModule
|
||||
WebSocketModule,
|
||||
ResourceModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [TrpcService, TrpcRouter, Logger],
|
||||
})
|
||||
export class TrpcModule { }
|
||||
export class TrpcModule {}
|
||||
|
|
|
@ -13,12 +13,11 @@ 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';
|
||||
import { ResourceRouter } from '../models/resource/resource.router';
|
||||
|
||||
@Injectable()
|
||||
export class TrpcRouter {
|
||||
logger = new Logger(TrpcRouter.name)
|
||||
logger = new Logger(TrpcRouter.name);
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly post: PostRouter,
|
||||
|
@ -32,13 +31,12 @@ 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
|
||||
) { }
|
||||
private readonly resource: ResourceRouter,
|
||||
) {}
|
||||
getRouter() {
|
||||
return;
|
||||
}
|
||||
appRouter = this.trpc.router({
|
||||
|
||||
transform: this.transform.router,
|
||||
post: this.post.router,
|
||||
department: this.department.router,
|
||||
|
@ -50,11 +48,9 @@ export class TrpcRouter {
|
|||
message: this.message.router,
|
||||
app_config: this.app_config.router,
|
||||
visitor: this.visitor.router,
|
||||
course: this.course.router,
|
||||
lecture: this.lecture.router,
|
||||
section: this.section.router
|
||||
resource: this.resource.router,
|
||||
});
|
||||
wss: WebSocketServer = undefined
|
||||
wss: WebSocketServer = undefined;
|
||||
|
||||
async applyMiddleware(app: INestApplication) {
|
||||
app.use(
|
||||
|
@ -65,7 +61,7 @@ export class TrpcRouter {
|
|||
onError(opts) {
|
||||
const { error, type, path, input, ctx, req } = opts;
|
||||
// console.error('TRPC Error:', error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
// applyWSSHandler({
|
||||
|
@ -75,4 +71,3 @@ export class TrpcRouter {
|
|||
// });
|
||||
}
|
||||
}
|
||||
export type AppRouter = TrpcRouter[`appRouter`];
|
||||
|
|
|
@ -5,6 +5,7 @@ import * as trpcExpress from '@trpc/server/adapters/express';
|
|||
import { db, JwtPayload, UserProfile, RolePerms } from '@nice/common';
|
||||
import { CreateWSSContextFnOptions } from '@trpc/server/adapters/ws';
|
||||
import { UserProfileService } from '@server/auth/utils';
|
||||
import { getClientIp } from './utils';
|
||||
type Context = Awaited<ReturnType<TrpcService['createExpressContext']>>;
|
||||
@Injectable()
|
||||
export class TrpcService {
|
||||
|
@ -12,9 +13,18 @@ export class TrpcService {
|
|||
|
||||
async createExpressContext(
|
||||
opts: trpcExpress.CreateExpressContextOptions,
|
||||
): Promise<{ staff: UserProfile | undefined }> {
|
||||
): Promise<{
|
||||
staff: UserProfile | undefined;
|
||||
ip: string;
|
||||
}> {
|
||||
const token = opts.req.headers.authorization?.split(' ')[1];
|
||||
return await UserProfileService.instance.getUserProfileByToken(token);
|
||||
const staff =
|
||||
await UserProfileService.instance.getUserProfileByToken(token);
|
||||
const ip = getClientIp(opts.req);
|
||||
return {
|
||||
staff: staff.staff,
|
||||
ip: ip,
|
||||
};
|
||||
}
|
||||
async createWSSContext(
|
||||
opts: CreateWSSContextFnOptions,
|
||||
|
@ -45,6 +55,7 @@ export class TrpcService {
|
|||
ctx: {
|
||||
// User value is confirmed to be non-null at this point
|
||||
staff: ctx.staff,
|
||||
ip: ctx.ip,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import { TrpcRouter } from './trpc.router';
|
||||
|
||||
export type AppRouter = TrpcRouter[`appRouter`];
|
|
@ -0,0 +1,15 @@
|
|||
export function getClientIp(req: any): string {
|
||||
let ip =
|
||||
req.ip ||
|
||||
(Array.isArray(req.headers['x-forwarded-for'])
|
||||
? req.headers['x-forwarded-for'][0]
|
||||
: req.headers['x-forwarded-for']) ||
|
||||
req.socket.remoteAddress;
|
||||
|
||||
// 如果是 IPv4-mapped IPv6 地址,转换为 IPv4
|
||||
if (typeof ip === 'string' && ip.startsWith('::ffff:')) {
|
||||
ip = ip.substring(7);
|
||||
}
|
||||
|
||||
return ip || '';
|
||||
}
|
|
@ -74,7 +74,7 @@ export class TusService implements OnModuleInit {
|
|||
title: getFilenameWithoutExt(upload.metadata.filename),
|
||||
fileId, // 移除最后的文件名
|
||||
url: upload.id,
|
||||
metadata: upload.metadata,
|
||||
meta: upload.metadata,
|
||||
status: ResourceStatus.UPLOADING,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -13,7 +13,7 @@ export default function InstructorCoursesPage() {
|
|||
const { user } = useAuth();
|
||||
|
||||
const { data: paginationRes, refetch } =
|
||||
api.course.findManyWithPagination.useQuery({
|
||||
api.post.findManyWithPagination.useQuery({
|
||||
page: currentPage,
|
||||
pageSize: 8,
|
||||
where: {
|
||||
|
|
|
@ -6,35 +6,42 @@ import { useAuth } from "@web/src/providers/auth-provider";
|
|||
|
||||
export default function StudentCoursesPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const { user } = useAuth()
|
||||
const { data: paginationRes, refetch } = api.course.findManyWithPagination.useQuery({
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: paginationRes, refetch } =
|
||||
api.post.findManyWithPagination.useQuery({
|
||||
page: currentPage,
|
||||
pageSize: 8,
|
||||
where: {
|
||||
enrollments: {
|
||||
some: {
|
||||
studentId: user?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
studentId: user?.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
refetch()
|
||||
refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-semibold text-slate-800 mb-4">我参加的课程</h1>
|
||||
<h1 className="text-3xl font-semibold text-slate-800 mb-4">
|
||||
我参加的课程
|
||||
</h1>
|
||||
</div>
|
||||
<CourseList
|
||||
totalPages={paginationRes?.totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
currentPage={currentPage}
|
||||
courses={paginationRes?.items as any}
|
||||
renderItem={(course) => <CourseCard course={course}></CourseCard>}>
|
||||
</CourseList>
|
||||
renderItem={(course) => (
|
||||
<CourseCard course={course}></CourseCard>
|
||||
)}></CourseList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import { Avatar, Menu, Dropdown } from 'antd';
|
||||
import { LogoutOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { useAuth } from '@web/src/providers/auth-provider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Avatar, Menu, Dropdown } from "antd";
|
||||
import {
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
UserAddOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export const UserMenu = () => {
|
||||
const { isAuthenticated, logout, user } = useAuth();
|
||||
|
@ -14,24 +19,41 @@ export const UserMenu = () => {
|
|||
<Menu.Item key="profile" className="px-4 py-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar className="bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold">
|
||||
{(user?.showname || user?.username || '')[0]?.toUpperCase()}
|
||||
{(user?.showname ||
|
||||
user?.username ||
|
||||
"")[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{user?.showname || user?.username}</span>
|
||||
<span className="text-xs text-gray-500">{user?.department?.name || user?.officerId}</span>
|
||||
<span className="font-medium">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{user?.department?.name || user?.officerId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="settings" icon={<SettingOutlined />} className="px-4">
|
||||
<Menu.Item
|
||||
key="user-settings"
|
||||
icon={<UserSwitchOutlined />}
|
||||
className="px-4">
|
||||
个人设置
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="settings"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => {
|
||||
navigate("/admin/staff");
|
||||
}}
|
||||
className="px-4">
|
||||
后台管理
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="logout"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={async () => await logout()}
|
||||
className="px-4 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
>
|
||||
className="px-4 text-red-500 hover:text-red-600 hover:bg-red-50">
|
||||
退出登录
|
||||
</Menu.Item>
|
||||
</>
|
||||
|
@ -39,8 +61,7 @@ export const UserMenu = () => {
|
|||
<Menu.Item
|
||||
key="login"
|
||||
onClick={() => navigate("/login")}
|
||||
className="px-4 text-blue-500 hover:text-blue-600 hover:bg-blue-50"
|
||||
>
|
||||
className="px-4 text-blue-500 hover:text-blue-600 hover:bg-blue-50">
|
||||
登录/注册
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Quill from 'quill';
|
||||
import 'quill/dist/quill.snow.css'; // 引入默认样式
|
||||
import QuillCharCounter from './QuillCharCounter';
|
||||
import { defaultModules } from './constants';
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import Quill from "quill";
|
||||
import "quill/dist/quill.snow.css"; // 引入默认样式
|
||||
import QuillCharCounter from "./QuillCharCounter";
|
||||
import { defaultModules } from "./constants";
|
||||
|
||||
interface QuillEditorProps {
|
||||
value?: string;
|
||||
onChange?: (content: string) => void;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
theme?: 'snow' | 'bubble';
|
||||
theme?: "snow" | "bubble";
|
||||
modules?: any;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
@ -23,13 +23,13 @@ interface QuillEditorProps {
|
|||
maxRows?: number;
|
||||
}
|
||||
const QuillEditor: React.FC<QuillEditorProps> = ({
|
||||
value = '',
|
||||
value = "",
|
||||
onChange,
|
||||
placeholder = '请输入内容...',
|
||||
placeholder = "请输入内容...",
|
||||
readOnly = false,
|
||||
theme = 'snow',
|
||||
theme = "snow",
|
||||
modules = defaultModules,
|
||||
className = '',
|
||||
className = "",
|
||||
style = {},
|
||||
onFocus,
|
||||
onBlur,
|
||||
|
@ -38,7 +38,7 @@ const QuillEditor: React.FC<QuillEditorProps> = ({
|
|||
maxLength,
|
||||
minLength = 0,
|
||||
minRows = 1,
|
||||
maxRows
|
||||
maxRows,
|
||||
}) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const quillRef = useRef<Quill | null>(null);
|
||||
|
@ -48,23 +48,26 @@ const QuillEditor: React.FC<QuillEditorProps> = ({
|
|||
if (!quillRef.current) return;
|
||||
const editor = quillRef.current;
|
||||
// 获取文本并处理换行符
|
||||
const text = editor.getText().replace(/\n$/, '');
|
||||
const text = editor.getText().replace(/\n$/, "");
|
||||
const textLength = text.length;
|
||||
|
||||
// 处理最大长度限制
|
||||
if (maxLength && textLength > maxLength) {
|
||||
// 暂时移除事件监听器
|
||||
editor.off('text-change', handleTextChange);
|
||||
editor.off("text-change", handleTextChange);
|
||||
|
||||
// 获取当前选区
|
||||
const selection = editor.getSelection();
|
||||
const delta = editor.getContents();
|
||||
let length = 0;
|
||||
const newDelta = delta.ops?.reduce((acc: any, op: any) => {
|
||||
if (typeof op.insert === 'string') {
|
||||
if (typeof op.insert === "string") {
|
||||
const remainingLength = maxLength - length;
|
||||
if (length < maxLength) {
|
||||
const truncatedText = op.insert.slice(0, remainingLength);
|
||||
const truncatedText = op.insert.slice(
|
||||
0,
|
||||
remainingLength
|
||||
);
|
||||
length += truncatedText.length;
|
||||
acc.push({ ...op, insert: truncatedText });
|
||||
}
|
||||
|
@ -80,11 +83,11 @@ const QuillEditor: React.FC<QuillEditorProps> = ({
|
|||
editor.setSelection(Math.min(selection.index, maxLength));
|
||||
}
|
||||
// 重新计算截断后的实际长度
|
||||
const finalText = editor.getText().replace(/\n$/, '');
|
||||
const finalText = editor.getText().replace(/\n$/, "");
|
||||
setCharCount(finalText.length);
|
||||
|
||||
// 重新绑定事件监听器
|
||||
editor.on('text-change', handleTextChange);
|
||||
editor.on("text-change", handleTextChange);
|
||||
} else {
|
||||
// 如果没有超出最大长度,直接更新字符计数
|
||||
setCharCount(textLength);
|
||||
|
@ -121,27 +124,51 @@ const QuillEditor: React.FC<QuillEditorProps> = ({
|
|||
}
|
||||
quillRef.current.on(Quill.events.TEXT_CHANGE, handleTextChange);
|
||||
if (onKeyDown) {
|
||||
quillRef.current.root.addEventListener('keydown', onKeyDown);
|
||||
quillRef.current.root.addEventListener("keydown", onKeyDown);
|
||||
}
|
||||
if (onKeyUp) {
|
||||
quillRef.current.root.addEventListener('keyup', onKeyUp);
|
||||
quillRef.current.root.addEventListener("keyup", onKeyUp);
|
||||
}
|
||||
isMounted.current = true;
|
||||
}
|
||||
}, [theme, modules, placeholder, readOnly, onFocus, onBlur, onKeyDown, onKeyUp, maxLength, minLength]); // 添加所有相关的依赖
|
||||
}, [
|
||||
theme,
|
||||
modules,
|
||||
placeholder,
|
||||
readOnly,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
maxLength,
|
||||
minLength,
|
||||
]); // 添加所有相关的依赖
|
||||
useEffect(() => {
|
||||
if (quillRef.current) {
|
||||
const editor = editorRef.current?.querySelector('.ql-editor') as HTMLElement;
|
||||
const editor = editorRef.current?.querySelector(
|
||||
".ql-editor"
|
||||
) as HTMLElement;
|
||||
if (editor) {
|
||||
const lineHeight = parseInt(window.getComputedStyle(editor).lineHeight, 10);
|
||||
const paddingTop = parseInt(window.getComputedStyle(editor).paddingTop, 10);
|
||||
const paddingBottom = parseInt(window.getComputedStyle(editor).paddingBottom, 10);
|
||||
const minHeight = lineHeight * minRows + paddingTop + paddingBottom;
|
||||
const lineHeight = parseInt(
|
||||
window.getComputedStyle(editor).lineHeight,
|
||||
10
|
||||
);
|
||||
const paddingTop = parseInt(
|
||||
window.getComputedStyle(editor).paddingTop,
|
||||
10
|
||||
);
|
||||
const paddingBottom = parseInt(
|
||||
window.getComputedStyle(editor).paddingBottom,
|
||||
10
|
||||
);
|
||||
const minHeight =
|
||||
lineHeight * minRows + paddingTop + paddingBottom;
|
||||
editor.style.minHeight = `${minHeight}px`;
|
||||
if (maxRows) {
|
||||
const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom;
|
||||
const maxHeight =
|
||||
lineHeight * maxRows + paddingTop + paddingBottom;
|
||||
editor.style.maxHeight = `${maxHeight}px`;
|
||||
editor.style.overflowY = 'auto';
|
||||
editor.style.overflowY = "auto";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,21 +1,20 @@
|
|||
import { useFormContext } from "react-hook-form";
|
||||
import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Reorder } from "framer-motion";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import FormError from "./FormError";
|
||||
import React, { useState } from "react";
|
||||
import { Input, Button, Form, Typography } from "antd";
|
||||
import type { InputProps } from "antd";
|
||||
import { UUIDGenerator } from "@nice/common";
|
||||
|
||||
interface ArrayFieldProps {
|
||||
name: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
addButtonText?: string;
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
inputProps?: Partial<InputProps>;
|
||||
}
|
||||
|
||||
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,
|
||||
|
@ -23,128 +22,123 @@ export function FormArrayField({
|
|||
addButtonText = "添加项目",
|
||||
inputProps = {},
|
||||
}: ArrayFieldProps) {
|
||||
const {
|
||||
register,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
trigger,
|
||||
} = useFormContext();
|
||||
const [items, setItems] = useState<ItemType[]>([]);
|
||||
|
||||
// 添加 watch 监听
|
||||
const watchedValues = watch(name);
|
||||
const [items, setItems] = useState<ItemType[]>(
|
||||
() =>
|
||||
(watchedValues as string[])?.map((value) => ({
|
||||
id: UUIDGenerator.generate(),
|
||||
value,
|
||||
})) || []
|
||||
);
|
||||
// 使用 useEffect 监听表单值变化
|
||||
useEffect(() => {
|
||||
if (watchedValues) {
|
||||
const newItems = watchedValues.map(
|
||||
(value: string, index: number) => {
|
||||
// 尽量保持现有的 id
|
||||
return {
|
||||
id: items[index]?.id || UUIDGenerator.generate(),
|
||||
value,
|
||||
};
|
||||
}
|
||||
);
|
||||
setItems(newItems);
|
||||
}
|
||||
}, [watchedValues]);
|
||||
const error = errors[name]?.message as string;
|
||||
const updateItems = (newItems: ItemType[]) => {
|
||||
setItems(newItems);
|
||||
setValue(
|
||||
name,
|
||||
newItems.map((item) => item.value),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
<Form.List name={name}>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<Form.Item label={label}>
|
||||
<div className="space-y-3">
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={items}
|
||||
onReorder={updateItems}
|
||||
className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
{fields.map((field, index) => (
|
||||
<Reorder.Item
|
||||
key={item.id}
|
||||
value={item}
|
||||
key={items[index]?.id || field.key}
|
||||
value={
|
||||
items[index] || {
|
||||
id: field.key,
|
||||
value: "",
|
||||
}
|
||||
}
|
||||
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}`)}
|
||||
exit={{ opacity: 0, y: -20 }}>
|
||||
<div className="relative flex items-center group">
|
||||
<Form.Item
|
||||
{...field}
|
||||
validateTrigger={[
|
||||
"onChange",
|
||||
"onBlur",
|
||||
]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
whitespace: true,
|
||||
message:
|
||||
"请输入内容或删除此项",
|
||||
},
|
||||
]}
|
||||
noStyle>
|
||||
<Input
|
||||
{...inputProps}
|
||||
value={item.value}
|
||||
placeholder={placeholder}
|
||||
style={{ width: "100%" }}
|
||||
// suffix={
|
||||
// inputProps.maxLength && (
|
||||
// <Typography.Text type="secondary">
|
||||
// {inputProps.maxLength -
|
||||
// (
|
||||
// Form.useWatch(
|
||||
// [
|
||||
// name,
|
||||
// field.name,
|
||||
// ]
|
||||
// ) || ""
|
||||
// ).length}
|
||||
// </Typography.Text>
|
||||
// )
|
||||
// }
|
||||
onChange={(e) => {
|
||||
const newItems = items.map((i) =>
|
||||
i.id === item.id
|
||||
? {
|
||||
...i,
|
||||
// 更新 items 状态
|
||||
const newItems = [...items];
|
||||
if (!newItems[index]) {
|
||||
newItems[index] = {
|
||||
id: field.key.toString(),
|
||||
value: e.target
|
||||
.value,
|
||||
};
|
||||
} else {
|
||||
newItems[index].value =
|
||||
e.target.value;
|
||||
}
|
||||
: i
|
||||
);
|
||||
updateItems(newItems);
|
||||
setItems(newItems);
|
||||
}}
|
||||
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(
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
className="ml-2 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
setItems(
|
||||
items.filter(
|
||||
(i) => i.id !== item.id
|
||||
(_, i) => i !== index
|
||||
)
|
||||
)
|
||||
}
|
||||
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([
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
add();
|
||||
setItems([
|
||||
...items,
|
||||
{ id: UUIDGenerator.generate(), 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>
|
||||
</Button>
|
||||
|
||||
<Form.ErrorList errors={errors} />
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.List>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,156 @@
|
|||
import { env } from "@web/src/env";
|
||||
import { message, Progress, Spin, theme } from "antd";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useTusUpload } from "@web/src/hooks/useTusUpload";
|
||||
import { Avatar } from "antd/lib";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export interface AvatarUploaderProps {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
compressed?: boolean;
|
||||
style?: React.CSSProperties; // 添加style属性
|
||||
}
|
||||
|
||||
interface UploadingFile {
|
||||
name: string;
|
||||
progress: number;
|
||||
status: "uploading" | "done" | "error";
|
||||
fileId?: string;
|
||||
url?: string;
|
||||
compressedUrl?: string;
|
||||
fileKey?: string;
|
||||
}
|
||||
|
||||
const AvatarUploader: React.FC<AvatarUploaderProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
compressed = false,
|
||||
className,
|
||||
placeholder = "点击上传",
|
||||
style, // 解构style属性
|
||||
}) => {
|
||||
const { handleFileUpload, uploadProgress } = useTusUpload();
|
||||
const [file, setFile] = useState<UploadingFile | null>(null);
|
||||
const avatarRef = useRef<HTMLImageElement>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string>(value || "");
|
||||
|
||||
const [compressedUrl, setCompressedUrl] = useState<string>(value || "");
|
||||
const [url, setUrl] = useState<string>(value || "");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// 在组件中定义 key 状态
|
||||
const [avatarKey, setAvatarKey] = useState(0);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target.files?.[0];
|
||||
if (!selectedFile) return;
|
||||
// Create an object URL for the selected file
|
||||
const objectUrl = URL.createObjectURL(selectedFile);
|
||||
setPreviewUrl(objectUrl);
|
||||
setFile({
|
||||
name: selectedFile.name,
|
||||
progress: 0,
|
||||
status: "uploading",
|
||||
fileKey: `${selectedFile.name}-${Date.now()}`,
|
||||
});
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const uploadedUrl = await new Promise<string>((resolve, reject) => {
|
||||
handleFileUpload(
|
||||
selectedFile,
|
||||
(result) => {
|
||||
setFile((prev) => ({
|
||||
...prev!,
|
||||
progress: 100,
|
||||
status: "done",
|
||||
fileId: result.fileId,
|
||||
url: result.url,
|
||||
compressedUrl: result.compressedUrl,
|
||||
}));
|
||||
|
||||
setUrl(result.url);
|
||||
setCompressedUrl(result.compressedUrl);
|
||||
// 直接使用 result 中的最新值
|
||||
resolve(compressed ? result.compressedUrl : result.url);
|
||||
},
|
||||
(error) => {
|
||||
reject(error);
|
||||
},
|
||||
file?.fileKey
|
||||
);
|
||||
});
|
||||
// await new Promise((resolve) => setTimeout(resolve,4999)); // 方法1:使用 await 暂停执行
|
||||
// 使用 resolved 的最新值调用 onChange
|
||||
// 强制刷新 Avatar 组件
|
||||
setAvatarKey((prev) => prev + 1); // 修改 key 强制重新挂载
|
||||
onChange?.(uploadedUrl);
|
||||
console.log(uploadedUrl);
|
||||
toast.success("头像上传成功");
|
||||
} catch (error) {
|
||||
console.error("上传错误:", error);
|
||||
toast.error("头像上传失败");
|
||||
setFile((prev) => ({ ...prev!, status: "error" }));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerUpload = () => {
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-24 h-24 overflow-hidden cursor-pointer ${className}`}
|
||||
onClick={triggerUpload}
|
||||
style={{
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
background: token.colorBgContainer,
|
||||
...style, // 应用外部传入的样式
|
||||
}}>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
onChange={handleChange}
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<Avatar
|
||||
key={avatarKey}
|
||||
ref={avatarRef}
|
||||
src={previewUrl}
|
||||
shape="square"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full text-sm text-gray-500">
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
{file && file.status === "uploading" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-white bg-opacity-75">
|
||||
<Progress
|
||||
percent={Math.round(
|
||||
uploadProgress?.[file.fileKey!] || 0
|
||||
)}
|
||||
showInfo={false}
|
||||
strokeColor={token.colorPrimary}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvatarUploader;
|
|
@ -1,237 +0,0 @@
|
|||
// FileUploader.tsx
|
||||
import React, { useRef, memo, useState } from "react";
|
||||
import {
|
||||
CloudArrowUpIcon,
|
||||
XMarkIcon,
|
||||
DocumentIcon,
|
||||
ExclamationCircleIcon,
|
||||
CheckCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTusUpload } from "@web/src/hooks/useTusUpload";
|
||||
|
||||
interface FileUploaderProps {
|
||||
endpoint?: string;
|
||||
onSuccess?: (url: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
maxSize?: number;
|
||||
allowedTypes?: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface FileItemProps {
|
||||
file: File;
|
||||
progress?: number;
|
||||
onRemove: (name: string) => void;
|
||||
isUploaded: boolean;
|
||||
}
|
||||
|
||||
const FileItem: React.FC<FileItemProps> = memo(
|
||||
({ file, progress, onRemove, isUploaded }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200">
|
||||
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">
|
||||
{file.name}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onRemove(file.name)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
|
||||
aria-label={`Remove ${file.name}`}>
|
||||
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
{!isUploaded && progress !== undefined && (
|
||||
<div className="mt-2">
|
||||
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
|
||||
<motion.div
|
||||
className="bg-blue-500 h-1.5 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isUploaded && (
|
||||
<div className="mt-2 flex items-center text-green-500">
|
||||
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
||||
<span className="text-xs">上传完成</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
|
||||
const FileUploader: React.FC<FileUploaderProps> = ({
|
||||
endpoint = "",
|
||||
onSuccess,
|
||||
onError,
|
||||
maxSize = 100,
|
||||
placeholder = "点击或拖拽文件到这里上传",
|
||||
allowedTypes = ["*/*"],
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [files, setFiles] = useState<
|
||||
Array<{ file: File; isUploaded: boolean }>
|
||||
>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { progress, isUploading, uploadError, handleFileUpload } =
|
||||
useTusUpload();
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
toast.error(error.message);
|
||||
onError?.(error);
|
||||
};
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setIsDragging(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateFile = (file: File) => {
|
||||
if (file.size > maxSize * 1024 * 1024) {
|
||||
throw new Error(`文件大小不能超过 ${maxSize}MB`);
|
||||
}
|
||||
if (
|
||||
!allowedTypes.includes("*/*") &&
|
||||
!allowedTypes.includes(file.type)
|
||||
) {
|
||||
throw new Error(
|
||||
`不支持的文件类型。支持的类型: ${allowedTypes.join(", ")}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFile = (file: File) => {
|
||||
try {
|
||||
validateFile(file);
|
||||
handleFileUpload(
|
||||
file,
|
||||
(upload) => {
|
||||
onSuccess?.(upload.url || "");
|
||||
setFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.file.name === file.name
|
||||
? { ...f, isUploaded: true }
|
||||
: f
|
||||
)
|
||||
);
|
||||
},
|
||||
handleError
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
setFiles((prev) => [
|
||||
...prev,
|
||||
...droppedFiles.map((file) => ({ file, isUploaded: false })),
|
||||
]);
|
||||
droppedFiles.forEach(uploadFile);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const selectedFiles = Array.from(e.target.files);
|
||||
setFiles((prev) => [
|
||||
...prev,
|
||||
...selectedFiles.map((file) => ({ file, isUploaded: false })),
|
||||
]);
|
||||
selectedFiles.forEach(uploadFile);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (fileName: string) => {
|
||||
setFiles((prev) => prev.filter(({ file }) => file.name !== fileName));
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
relative flex flex-col items-center justify-center w-full h-32
|
||||
border-2 border-dashed rounded-lg cursor-pointer
|
||||
transition-colors duration-200 ease-in-out
|
||||
${
|
||||
isDragging
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-300 hover:border-blue-500"
|
||||
}
|
||||
`}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
accept={allowedTypes.join(",")}
|
||||
className="hidden"
|
||||
/>
|
||||
<CloudArrowUpIcon className="w-10 h-10 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-500">{placeholder}</p>
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg">
|
||||
<p className="text-blue-500 font-medium">
|
||||
释放文件以上传
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
<div className="space-y-3">
|
||||
{files.map(({ file, isUploaded }) => (
|
||||
<FileItem
|
||||
key={file.name}
|
||||
file={file}
|
||||
progress={isUploaded ? 100 : progress}
|
||||
onRemove={removeFile}
|
||||
isUploaded={isUploaded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
|
||||
{uploadError && (
|
||||
<div className="flex items-center text-red-500 text-sm">
|
||||
<ExclamationCircleIcon className="w-4 h-4 mr-1" />
|
||||
<span>{uploadError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploader;
|
|
@ -0,0 +1,234 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
UploadOutlined,
|
||||
CheckCircleOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Upload, Progress, Button } from "antd";
|
||||
import type { UploadFile } from "antd";
|
||||
import { useTusUpload } from "@web/src/hooks/useTusUpload";
|
||||
import toast from "react-hot-toast";
|
||||
import { getCompressedImageUrl } from "@nice/utils";
|
||||
import { api } from "@nice/client";
|
||||
|
||||
export interface TusUploaderProps {
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
interface UploadingFile {
|
||||
name: string;
|
||||
progress: number;
|
||||
status: "uploading" | "done" | "error";
|
||||
fileId?: string;
|
||||
fileKey?: string;
|
||||
}
|
||||
|
||||
export const TusUploader = ({
|
||||
value = [],
|
||||
onChange,
|
||||
multiple = true,
|
||||
}: TusUploaderProps) => {
|
||||
const { data: files } = api.resource.findMany.useQuery({
|
||||
where: {
|
||||
fileId: { in: value },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fileId: true,
|
||||
title: true,
|
||||
},
|
||||
});
|
||||
const { handleFileUpload, uploadProgress } = useTusUpload();
|
||||
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
|
||||
const [completedFiles, setCompletedFiles] = useState<UploadingFile[]>(
|
||||
() =>
|
||||
value?.map((fileId) => ({
|
||||
name: `文件 ${fileId}`,
|
||||
progress: 100,
|
||||
status: "done" as const,
|
||||
fileId,
|
||||
})) || []
|
||||
);
|
||||
const [uploadResults, setUploadResults] = useState<string[]>(value || []);
|
||||
|
||||
const handleRemoveFile = useCallback(
|
||||
(fileId: string) => {
|
||||
setCompletedFiles((prev) =>
|
||||
prev.filter((f) => f.fileId !== fileId)
|
||||
);
|
||||
setUploadResults((prev) => {
|
||||
const newValue = prev.filter((id) => id !== fileId);
|
||||
onChange?.(newValue);
|
||||
return newValue;
|
||||
});
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
// 新增:处理删除上传中的失败文件
|
||||
const handleRemoveUploadingFile = useCallback((fileKey: string) => {
|
||||
setUploadingFiles((prev) => prev.filter((f) => f.fileKey !== fileKey));
|
||||
}, []);
|
||||
|
||||
const handleBeforeUpload = useCallback(
|
||||
(file: File) => {
|
||||
const fileKey = `${file.name}-${Date.now()}`;
|
||||
|
||||
setUploadingFiles((prev) => [
|
||||
...prev,
|
||||
{
|
||||
name: file.name,
|
||||
progress: 0,
|
||||
status: "uploading",
|
||||
fileKey,
|
||||
},
|
||||
]);
|
||||
|
||||
handleFileUpload(
|
||||
file,
|
||||
(result) => {
|
||||
setCompletedFiles((prev) => [
|
||||
...prev,
|
||||
{
|
||||
name: file.name,
|
||||
progress: 100,
|
||||
status: "done",
|
||||
fileId: result.fileId,
|
||||
},
|
||||
]);
|
||||
|
||||
setUploadingFiles((prev) =>
|
||||
prev.filter((f) => f.fileKey !== fileKey)
|
||||
);
|
||||
|
||||
setUploadResults((prev) => {
|
||||
// 如果是单文件模式,则替换现有文件
|
||||
const newValue = multiple
|
||||
? [...prev, result.fileId]
|
||||
: [result.fileId];
|
||||
onChange?.(newValue);
|
||||
return newValue;
|
||||
});
|
||||
|
||||
// 单文件模式下,清除之前的完成文件
|
||||
if (!multiple) {
|
||||
setCompletedFiles([
|
||||
{
|
||||
name: file.name,
|
||||
progress: 100,
|
||||
status: "done",
|
||||
fileId: result.fileId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error("上传错误:", error);
|
||||
toast.error(
|
||||
`上传失败: ${error instanceof Error ? error.message : "未知错误"}`
|
||||
);
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.fileKey === fileKey
|
||||
? { ...f, status: "error" }
|
||||
: f
|
||||
)
|
||||
);
|
||||
},
|
||||
fileKey
|
||||
);
|
||||
|
||||
return false;
|
||||
},
|
||||
[handleFileUpload, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Upload.Dragger
|
||||
name="files"
|
||||
multiple={multiple}
|
||||
showUploadList={false}
|
||||
style={{ background: "transparent", borderStyle: "none" }}
|
||||
beforeUpload={handleBeforeUpload}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
点击或拖拽文件到此区域进行上传
|
||||
</p>
|
||||
<p className="ant-upload-hint">
|
||||
{multiple ? "支持单个或批量上传文件" : "仅支持上传单个文件"}
|
||||
</p>
|
||||
|
||||
<div className="px-2 py-0 rounded mt-1">
|
||||
{uploadingFiles.map((file) => (
|
||||
<div
|
||||
key={file.fileKey}
|
||||
className="flex flex-col gap-1 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{file.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
percent={
|
||||
file.status === "done"
|
||||
? 100
|
||||
: Math.round(
|
||||
uploadProgress?.[
|
||||
file.fileKey!
|
||||
] || 0
|
||||
)
|
||||
}
|
||||
status={
|
||||
file.status === "error"
|
||||
? "exception"
|
||||
: "active"
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
{file.status === "error" && (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (file.fileKey)
|
||||
handleRemoveUploadingFile(
|
||||
file.fileKey
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{completedFiles.map((file) => (
|
||||
<div
|
||||
key={file.fileId}
|
||||
className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleOutlined className="text-green-500" />
|
||||
<span className="text-sm">{file.name}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (file.fileId)
|
||||
handleRemoveFile(file.fileId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,198 @@
|
|||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { Avatar, Tag, theme, Tooltip } from "antd";
|
||||
import React, {
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
CSSProperties,
|
||||
} from "react";
|
||||
import { SyncOutlined } from "@ant-design/icons";
|
||||
import * as Y from "yjs";
|
||||
import { stringToColor, YWsProvider } from "@nice/common";
|
||||
import { lightenColor } from "@nice/client";
|
||||
import { useLocalSettings } from "@web/src/hooks/useLocalSetting";
|
||||
import Breadcrumb from "../element/breadcrumb";
|
||||
|
||||
interface AdminHeaderProps {
|
||||
children?: ReactNode;
|
||||
roomId?: string;
|
||||
awarePlaceholder?: string;
|
||||
borderless?: boolean;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AdminHeader: React.FC<AdminHeaderProps> = ({
|
||||
className,
|
||||
style,
|
||||
borderless = false,
|
||||
children,
|
||||
roomId,
|
||||
awarePlaceholder = "协作人员",
|
||||
}) => {
|
||||
const { user, sessionId, accessToken } = useAuth();
|
||||
const [userStates, setUserStates] = useState<Map<string, any>>(new Map());
|
||||
const { token } = theme.useToken();
|
||||
const providerRef = useRef<YWsProvider | null>(null);
|
||||
const { websocketUrl } = useLocalSettings();
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
// 如果已经连接或缺少必要参数,则返回
|
||||
if (!user || !roomId || !websocketUrl) {
|
||||
return;
|
||||
}
|
||||
// 设置延时,避免立即连接
|
||||
const connectTimeout = setTimeout(() => {
|
||||
try {
|
||||
const ydoc = new Y.Doc();
|
||||
const provider = new YWsProvider(
|
||||
websocketUrl + "/yjs",
|
||||
roomId,
|
||||
ydoc,
|
||||
{
|
||||
params: {
|
||||
userId: user?.id,
|
||||
sessionId,
|
||||
},
|
||||
}
|
||||
);
|
||||
providerRef.current = provider;
|
||||
const { awareness } = provider;
|
||||
const updateAwarenessData = () => {
|
||||
const uniqueStates = new Map<string, any>();
|
||||
awareness.getStates().forEach((value, key) => {
|
||||
const sessionId = value?.user?.sessionId;
|
||||
if (sessionId) {
|
||||
uniqueStates.set(sessionId, value);
|
||||
}
|
||||
});
|
||||
setUserStates(uniqueStates);
|
||||
};
|
||||
|
||||
const localState = {
|
||||
user: {
|
||||
id: user.id,
|
||||
showname: user?.showname || user.username,
|
||||
deptName: user.department?.name,
|
||||
sessionId,
|
||||
},
|
||||
};
|
||||
|
||||
awareness.setLocalStateField("user", localState.user);
|
||||
awareness.on("change", updateAwarenessData);
|
||||
updateAwarenessData();
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
awareness.setLocalState(null);
|
||||
provider.disconnect();
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
// 定义清理函数
|
||||
cleanup = () => {
|
||||
if (providerRef.current) {
|
||||
awareness.off("change", updateAwarenessData);
|
||||
awareness.setLocalState(null);
|
||||
provider.disconnect();
|
||||
providerRef.current = null;
|
||||
}
|
||||
|
||||
setUserStates(new Map());
|
||||
window.removeEventListener(
|
||||
"beforeunload",
|
||||
handleBeforeUnload
|
||||
);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("WebSocket connection error:", error);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
clearTimeout(connectTimeout);
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}, [roomId, user, websocketUrl, sessionId]);
|
||||
|
||||
// 其余渲染代码保持不变...
|
||||
const renderAvatars = () =>
|
||||
Array.from(userStates.entries()).map(([key, value]) => (
|
||||
<Tooltip
|
||||
color="white"
|
||||
title={
|
||||
<span className="text-tertiary-300">
|
||||
{value?.user.deptName && (
|
||||
<span className="mr-2 text-primary">
|
||||
{value?.user?.deptName}
|
||||
</span>
|
||||
)}
|
||||
<span className="">
|
||||
{value?.user?.showname || "匿名用户"}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
key={key}>
|
||||
<Avatar
|
||||
className="cursor-pointer"
|
||||
src={value?.user?.avatarUrl}
|
||||
size={35}
|
||||
style={{
|
||||
borderColor: lightenColor(
|
||||
stringToColor(value?.user?.sessionId),
|
||||
30
|
||||
),
|
||||
borderWidth: 3,
|
||||
color: lightenColor(
|
||||
stringToColor(value?.user?.sessionId),
|
||||
30
|
||||
),
|
||||
fontWeight: "bold",
|
||||
background: stringToColor(value?.user?.sessionId),
|
||||
}}>
|
||||
{!value?.user?.avatarUrl &&
|
||||
(value?.user?.showname?.toUpperCase() || "匿名用户")}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-shrink-0 p-2 border-gray-200 flex justify-between ${
|
||||
borderless ? "" : "border-b"
|
||||
} ${className}`}
|
||||
style={{ height: "49px", ...style }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Breadcrumb />
|
||||
<div className="flex items-center gap-2">
|
||||
{roomId && (
|
||||
<Tag
|
||||
icon={<SyncOutlined spin />}
|
||||
color={token.colorPrimaryHover}>
|
||||
{awarePlaceholder}
|
||||
</Tag>
|
||||
)}
|
||||
<Avatar.Group
|
||||
max={{
|
||||
count: 35,
|
||||
style: {
|
||||
backgroundColor: token.colorPrimaryBg,
|
||||
color: token.colorPrimary,
|
||||
padding: 10,
|
||||
},
|
||||
}}>
|
||||
{renderAvatars()}
|
||||
</Avatar.Group>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminHeader;
|
|
@ -0,0 +1,20 @@
|
|||
import { Outlet } from "react-router-dom";
|
||||
import { Layout } from "antd";
|
||||
|
||||
import { adminRoute } from "@web/src/routes/admin-route";
|
||||
import AdminSidebar from "./AdminSidebar";
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
export default function AdminLayout() {
|
||||
return (
|
||||
<Layout className="min-h-screen">
|
||||
<AdminSidebar routes={adminRoute.children || []} />
|
||||
<Layout>
|
||||
<Content>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { useState, useMemo } from "react";
|
||||
import { NavLink, matchPath, useLocation, useMatches } from "react-router-dom";
|
||||
import { Layout, Menu, theme } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import { CustomRouteObject } from "@web/src/routes/types";
|
||||
|
||||
const { Sider } = Layout;
|
||||
const { useToken } = theme;
|
||||
|
||||
type SidebarProps = {
|
||||
routes: CustomRouteObject[];
|
||||
};
|
||||
|
||||
export default function AdminSidebar({ routes }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { token } = useToken();
|
||||
const matches = useMatches();
|
||||
console.log(matches);
|
||||
const menuItems: MenuProps["items"] = useMemo(
|
||||
() =>
|
||||
routes.map((route) => ({
|
||||
key: route.path,
|
||||
icon: route.icon,
|
||||
label: <NavLink to={route.path}>{route.name}</NavLink>,
|
||||
})),
|
||||
[routes]
|
||||
);
|
||||
|
||||
return (
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={(value) => setCollapsed(value)}
|
||||
width={150}
|
||||
className="h-screen sticky top-0"
|
||||
style={{
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}>
|
||||
<Menu
|
||||
theme="light"
|
||||
mode="inline"
|
||||
selectedKeys={routes
|
||||
.filter((route) =>
|
||||
matches.some((match) =>
|
||||
match.pathname.includes(route.path)
|
||||
)
|
||||
)
|
||||
.map((route) => route.path)}
|
||||
items={menuItems}
|
||||
className="border-r-0"
|
||||
style={{
|
||||
borderRight: 0,
|
||||
}}
|
||||
/>
|
||||
</Sider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
import { useLocation, Link, useMatches } from 'react-router-dom';
|
||||
import { theme } from 'antd';
|
||||
import { RightOutlined } from '@ant-design/icons';
|
||||
|
||||
export default function Breadcrumb() {
|
||||
let matches = useMatches();
|
||||
const { token } = theme.useToken()
|
||||
let crumbs = matches
|
||||
// first get rid of any matches that don't have handle and crumb
|
||||
.filter((match) => Boolean((match.handle as any)?.crumb))
|
||||
// now map them into an array of elements, passing the loader
|
||||
// data to each one
|
||||
.map((match) => (match.handle as any).crumb(match.data));
|
||||
|
||||
return (
|
||||
<ol className='flex items-center space-x-2 text-gray-600'>
|
||||
{crumbs.map((crumb, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<li className={`inline-flex items-center `}
|
||||
style={{
|
||||
color: (index === crumbs.length - 1) ? token.colorPrimaryText : token.colorTextSecondary,
|
||||
fontWeight: (index === crumbs.length - 1) ? "bold" : "normal",
|
||||
}}
|
||||
>
|
||||
{crumb}
|
||||
</li>
|
||||
{index < crumbs.length - 1 && (
|
||||
<li className='mx-2'>
|
||||
<RightOutlined></RightOutlined>
|
||||
</li>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export interface MenuItemType {
|
||||
icon: JSX.Element;
|
||||
label: string;
|
||||
action: () => void;
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { Button, Drawer, Modal } from "antd";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
|
||||
import { UserEditorContext } from "./usermenu";
|
||||
import UserForm from "./user-form";
|
||||
|
||||
export default function UserEditModal() {
|
||||
const { formLoading, modalOpen, setModalOpen, form } =
|
||||
useContext(UserEditorContext);
|
||||
const handleOk = () => {
|
||||
form.submit();
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
width={400}
|
||||
onOk={handleOk}
|
||||
open={modalOpen}
|
||||
confirmLoading={formLoading}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
}}
|
||||
title={"编辑个人信息"}>
|
||||
<UserForm />
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,216 @@
|
|||
import { Button, Form, Input, Spin, Switch, message } from "antd";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { useStaff } from "@nice/client";
|
||||
import DepartmentSelect from "@web/src/components/models/department/department-select";
|
||||
import { api } from "@nice/client";
|
||||
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader";
|
||||
import { StaffDto } from "@nice/common";
|
||||
import { UserEditorContext } from "./usermenu";
|
||||
import toast from "react-hot-toast";
|
||||
export default function StaffForm() {
|
||||
const { user } = useAuth();
|
||||
const { create, update } = useStaff(); // Ensure you have these methods in your hooks
|
||||
const {
|
||||
formLoading,
|
||||
modalOpen,
|
||||
setModalOpen,
|
||||
domainId,
|
||||
setDomainId,
|
||||
form,
|
||||
setFormLoading,
|
||||
} = useContext(UserEditorContext);
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
}: {
|
||||
data: StaffDto;
|
||||
isLoading: boolean;
|
||||
} = api.staff.findFirst.useQuery(
|
||||
{ where: { id: user?.id } },
|
||||
{ enabled: !!user?.id }
|
||||
);
|
||||
const { isRoot } = useAuth();
|
||||
async function handleFinish(values: any) {
|
||||
const {
|
||||
username,
|
||||
showname,
|
||||
deptId,
|
||||
domainId,
|
||||
password,
|
||||
phoneNumber,
|
||||
officerId,
|
||||
avatar,
|
||||
enabled,
|
||||
} = values;
|
||||
setFormLoading(true);
|
||||
try {
|
||||
if (data && user?.id) {
|
||||
await update.mutateAsync({
|
||||
where: { id: data.id },
|
||||
data: {
|
||||
username,
|
||||
deptId,
|
||||
avatar,
|
||||
officerId,
|
||||
showname,
|
||||
domainId,
|
||||
password,
|
||||
phoneNumber,
|
||||
enabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
toast.success("提交成功");
|
||||
setModalOpen(false);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
form.resetFields();
|
||||
console.log(data?.avatar);
|
||||
if (data) {
|
||||
form.setFieldValue("username", data.username);
|
||||
form.setFieldValue("showname", data.showname);
|
||||
form.setFieldValue("domainId", data.domainId);
|
||||
form.setFieldValue("deptId", data.deptId);
|
||||
form.setFieldValue("avatar", data.avatar);
|
||||
form.setFieldValue("phoneNumber", data.phoneNumber);
|
||||
form.setFieldValue("officerId", data.officerId);
|
||||
form.setFieldValue("enabled", data.enabled);
|
||||
}
|
||||
}, [data]);
|
||||
// useEffect(() => {
|
||||
// if (!data && domainId) {
|
||||
// form.setFieldValue("domainId", domainId);
|
||||
// form.setFieldValue("deptId", domainId);
|
||||
// }
|
||||
// }, [domainId, data as any]);
|
||||
return (
|
||||
<div className="relative">
|
||||
{isLoading && (
|
||||
<div className="absolute h-full inset-0 flex items-center justify-center bg-white bg-opacity-50 z-10">
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
<Form
|
||||
disabled={isLoading}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark="optional"
|
||||
autoComplete="off"
|
||||
onFinish={handleFinish}>
|
||||
<div className=" flex items-center gap-4 mb-2">
|
||||
<div>
|
||||
<Form.Item name={"avatar"} label="头像" noStyle>
|
||||
<AvatarUploader
|
||||
placeholder="点击上传头像"
|
||||
className="rounded-lg"
|
||||
style={{
|
||||
width: "120px",
|
||||
height: "150px",
|
||||
}}></AvatarUploader>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 flex-1">
|
||||
<Form.Item
|
||||
noStyle
|
||||
rules={[{ required: true }]}
|
||||
name={"username"}
|
||||
label="帐号">
|
||||
<Input
|
||||
placeholder="请输入用户名"
|
||||
allowClear
|
||||
autoComplete="new-username" // 使用非标准的自动完成值
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
rules={[{ required: true }]}
|
||||
name={"showname"}
|
||||
label="姓名">
|
||||
<Input
|
||||
placeholder="请输入姓名"
|
||||
allowClear
|
||||
autoComplete="new-name" // 使用非标准的自动完成值
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={"domainId"}
|
||||
label="所属域"
|
||||
noStyle
|
||||
rules={[{ required: true }]}>
|
||||
<DepartmentSelect
|
||||
placeholder="选择域"
|
||||
onChange={(value) => {
|
||||
setDomainId(value as string);
|
||||
}}
|
||||
domain={true}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
name={"deptId"}
|
||||
label="所属单位"
|
||||
rules={[{ required: true }]}>
|
||||
<DepartmentSelect rootId={domainId} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 flex-1">
|
||||
<Form.Item
|
||||
// rules={[
|
||||
// {
|
||||
// required: false,
|
||||
// pattern: /^\d{5,18}$/,
|
||||
// message: "请输入正确的证件号(数字)",
|
||||
// },
|
||||
// ]}
|
||||
noStyle
|
||||
name={"officerId"}>
|
||||
<Input
|
||||
placeholder="请输入证件号(可选)"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
pattern: /^\d{6,11}$/,
|
||||
message: "请输入正确的手机号(数字)",
|
||||
},
|
||||
]}
|
||||
noStyle
|
||||
name={"phoneNumber"}
|
||||
label="手机号">
|
||||
<Input
|
||||
placeholder="请输入手机号(可选)"
|
||||
autoComplete="new-phone" // 使用非标准的自动完成值
|
||||
spellCheck={false}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle label="密码" name={"password"}>
|
||||
<Input.Password
|
||||
placeholder="修改密码"
|
||||
spellCheck={false}
|
||||
visibilityToggle
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,259 @@
|
|||
import { useClickOutside } from "@web/src/hooks/useClickOutside";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
createContext,
|
||||
} from "react";
|
||||
import { Avatar } from "../../../common/element/Avatar";
|
||||
import {
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { FormInstance, Spin } from "antd";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MenuItemType } from "../types";
|
||||
import { RolePerms } from "@nice/common";
|
||||
import { useForm } from "antd/es/form/Form";
|
||||
import UserEditModal from "./user-edit-modal";
|
||||
const menuVariants = {
|
||||
hidden: { opacity: 0, scale: 0.95, y: -10 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: -10,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const UserEditorContext = createContext<{
|
||||
domainId: string;
|
||||
setDomainId: React.Dispatch<React.SetStateAction<string>>;
|
||||
modalOpen: boolean;
|
||||
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
form: FormInstance<any>;
|
||||
formLoading: boolean;
|
||||
setFormLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}>({
|
||||
modalOpen: false,
|
||||
domainId: undefined,
|
||||
setDomainId: undefined,
|
||||
setModalOpen: undefined,
|
||||
form: undefined,
|
||||
formLoading: undefined,
|
||||
setFormLoading: undefined,
|
||||
});
|
||||
|
||||
export function UserMenu() {
|
||||
const [form] = useForm();
|
||||
const [formLoading, setFormLoading] = useState<boolean>();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { user, logout, isLoading, hasSomePermissions } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||
const [domainId, setDomainId] = useState<string>();
|
||||
const toggleMenu = useCallback(() => {
|
||||
setShowMenu((prev) => !prev);
|
||||
}, []);
|
||||
const canManageAnyStaff = useMemo(() => {
|
||||
return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF);
|
||||
}, [user]);
|
||||
const menuItems: MenuItemType[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
icon: <UserOutlined className="text-lg" />,
|
||||
label: "个人信息",
|
||||
action: () => {
|
||||
setModalOpen(true);
|
||||
},
|
||||
},
|
||||
canManageAnyStaff && {
|
||||
icon: <SettingOutlined className="text-lg" />,
|
||||
label: "设置",
|
||||
action: () => {
|
||||
navigate("/admin/staff");
|
||||
},
|
||||
},
|
||||
// {
|
||||
// icon: <QuestionCircleOutlined className="text-lg" />,
|
||||
// label: '帮助',
|
||||
// action: () => { },
|
||||
// },
|
||||
{
|
||||
icon: <LogoutOutlined className="text-lg" />,
|
||||
label: "注销",
|
||||
action: () => logout(),
|
||||
},
|
||||
].filter(Boolean),
|
||||
[logout]
|
||||
);
|
||||
|
||||
const handleMenuItemClick = useCallback((action: () => void) => {
|
||||
action();
|
||||
setShowMenu(false);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-10 h-10">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UserEditorContext.Provider
|
||||
value={{
|
||||
formLoading,
|
||||
setFormLoading,
|
||||
form,
|
||||
domainId,
|
||||
modalOpen,
|
||||
setDomainId,
|
||||
setModalOpen,
|
||||
}}>
|
||||
<div ref={menuRef} className="relative">
|
||||
<motion.button
|
||||
aria-label="用户菜单"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={showMenu}
|
||||
aria-controls="user-menu"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={toggleMenu}
|
||||
className="flex items-center rounded-full transition-all duration-200 ease-in-out">
|
||||
{/* Avatar 容器,相对定位 */}
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-white hover:ring-[#00538E]/90
|
||||
transition-all duration-200 ease-in-out shadow-md
|
||||
hover:shadow-lg focus:outline-none
|
||||
focus:ring-2 focus:ring-[#00538E]/80 focus:ring-offset-2
|
||||
focus:ring-offset-white "
|
||||
/>
|
||||
{/* 小绿点 */}
|
||||
<span
|
||||
className="absolute bottom-0 right-0 h-3 w-3
|
||||
rounded-full bg-emerald-500 ring-2 ring-white
|
||||
shadow-sm transition-transform duration-200
|
||||
ease-in-out hover:scale-110"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 用户信息,显示在 Avatar 右侧 */}
|
||||
<div className="flex flex-col space-y-0.5 ml-3 items-start">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
<span className="text-xs text-white flex items-center gap-1.5">
|
||||
{user?.department?.name}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showMenu && (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
variants={menuVariants}
|
||||
role="menu"
|
||||
id="user-menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="user-menu-button"
|
||||
style={{ zIndex: 100 }}
|
||||
className="absolute right-0 mt-3 w-64 origin-top-right
|
||||
bg-white rounded-xl overflow-hidden shadow-lg
|
||||
border border-[#E5EDF5]">
|
||||
{/* User Profile Section */}
|
||||
<div
|
||||
className="px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white
|
||||
border-b border-[#E5EDF5] ">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-white shadow-sm"
|
||||
/>
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<span className="text-sm font-semibold text-[#00538E]">
|
||||
{user?.showname || user?.username}
|
||||
</span>
|
||||
<span className="text-xs text-[#718096] flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
在线
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="p-2">
|
||||
{menuItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
role="menuitem"
|
||||
tabIndex={showMenu ? 0 : -1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMenuItemClick(item.action);
|
||||
}}
|
||||
className={`flex items-center gap-3 w-full px-4 py-3
|
||||
text-sm font-medium rounded-lg transition-all
|
||||
focus:outline-none
|
||||
focus:ring-2 focus:ring-[#00538E]/20
|
||||
group relative overflow-hidden
|
||||
active:scale-[0.99]
|
||||
${
|
||||
item.label === "注销"
|
||||
? "text-[#B22234] hover:bg-red-50/80 hover:text-red-700"
|
||||
: "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]"
|
||||
}`}>
|
||||
<span
|
||||
className={`w-5 h-5 flex items-center justify-center
|
||||
transition-all duration-200 ease-in-out
|
||||
group-hover:scale-110 group-hover:rotate-6
|
||||
group-hover:translate-x-0.5 ${
|
||||
item.label === "注销"
|
||||
? "group-hover:text-red-600"
|
||||
: "group-hover:text-[#003F6A]"
|
||||
}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<UserEditModal></UserEditModal>
|
||||
</UserEditorContext.Provider>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { api, useCourse } from "@nice/client";
|
||||
import { api,} from "@nice/client";
|
||||
import { courseDetailSelect, CourseDto } from "@nice/common";
|
||||
import React, { createContext, ReactNode, useState } from "react";
|
||||
import { string } from "zod";
|
||||
|
||||
|
||||
interface CourseDetailContextType {
|
||||
editId?: string; // 添加 editId
|
||||
|
|
|
@ -1,95 +1,134 @@
|
|||
import { createContext, useContext, ReactNode, useEffect } from "react";
|
||||
import { useForm, FormProvider, SubmitHandler } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { CourseDto, CourseLevel, CourseStatus } from "@nice/common";
|
||||
import { api, useCourse } from "@nice/client";
|
||||
import toast from "react-hot-toast";
|
||||
import { Form, FormInstance, message } from "antd";
|
||||
import {
|
||||
CourseDto,
|
||||
CourseStatus,
|
||||
ObjectType,
|
||||
PostType,
|
||||
Taxonomy,
|
||||
} from "@nice/common";
|
||||
import { api, usePost } from "@nice/client";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
// 定义课程表单验证 Schema
|
||||
const courseSchema = z.object({
|
||||
title: z.string().min(1, '课程标题不能为空'),
|
||||
subTitle: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
thumbnail: z.string().url().nullish(),
|
||||
level: z.nativeEnum(CourseLevel),
|
||||
requirements: z.array(z.string()).nullish(),
|
||||
objectives: z.array(z.string()).nullish()
|
||||
});
|
||||
export type CourseFormData = z.infer<typeof courseSchema>;
|
||||
import { z } from "zod";
|
||||
|
||||
export type CourseFormData = {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
content?: string;
|
||||
thumbnail?: string;
|
||||
requirements?: string[];
|
||||
objectives?: string[];
|
||||
sections: any;
|
||||
};
|
||||
|
||||
interface CourseEditorContextType {
|
||||
onSubmit: SubmitHandler<CourseFormData>;
|
||||
editId?: string; // 添加 editId
|
||||
part?: string;
|
||||
onSubmit: (values: CourseFormData) => Promise<void>;
|
||||
editId?: string;
|
||||
course?: CourseDto;
|
||||
taxonomies?: Taxonomy[]; // 根据实际类型调整
|
||||
form: FormInstance<CourseFormData>; // 添加 form 到上下文
|
||||
}
|
||||
|
||||
interface CourseFormProviderProps {
|
||||
children: ReactNode;
|
||||
editId?: string; // 添加 editId 参数
|
||||
part?: string;
|
||||
editId?: string;
|
||||
}
|
||||
|
||||
const CourseEditorContext = createContext<CourseEditorContextType | null>(null);
|
||||
export function CourseFormProvider({ children, editId }: CourseFormProviderProps) {
|
||||
const { create, update } = useCourse()
|
||||
const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery({ where: { id: editId } }, { enabled: Boolean(editId) })
|
||||
const navigate = useNavigate()
|
||||
const methods = useForm<CourseFormData>({
|
||||
resolver: zodResolver(courseSchema),
|
||||
defaultValues: {
|
||||
level: CourseLevel.BEGINNER,
|
||||
requirements: [],
|
||||
objectives: []
|
||||
},
|
||||
|
||||
export function CourseFormProvider({
|
||||
children,
|
||||
editId,
|
||||
}: CourseFormProviderProps) {
|
||||
const [form] = Form.useForm<CourseFormData>();
|
||||
const { create, update, createCourse } = usePost();
|
||||
const { data: course }: { data: CourseDto } = api.post.findFirst.useQuery(
|
||||
{ where: { id: editId } },
|
||||
{ enabled: Boolean(editId) }
|
||||
);
|
||||
const {
|
||||
data: taxonomies,
|
||||
}: {
|
||||
data: Taxonomy[];
|
||||
} = api.taxonomy.getAll.useQuery({
|
||||
// type: ObjectType.COURSE,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (course) {
|
||||
const formData = {
|
||||
title: course.title,
|
||||
subTitle: course.subTitle,
|
||||
description: course.description,
|
||||
thumbnail: course.thumbnail,
|
||||
level: course.level,
|
||||
requirements: course.requirements,
|
||||
objectives: course.objectives,
|
||||
status: course.status,
|
||||
content: course.content,
|
||||
thumbnail: course?.meta?.thumbnail,
|
||||
requirements: course?.meta?.requirements,
|
||||
objectives: course?.meta?.objectives,
|
||||
};
|
||||
methods.reset(formData as any);
|
||||
form.setFieldsValue(formData);
|
||||
}
|
||||
}, [course, methods]);
|
||||
const onSubmit: SubmitHandler<CourseFormData> = async (data: CourseFormData) => {
|
||||
try {
|
||||
}, [course, form]);
|
||||
|
||||
const onSubmit = async (values: CourseFormData) => {
|
||||
console.log(values);
|
||||
const sections = values?.sections || [];
|
||||
const formattedValues = {
|
||||
...values,
|
||||
meta: {
|
||||
requirements: values.requirements,
|
||||
objectives: values.objectives,
|
||||
},
|
||||
};
|
||||
delete formattedValues.requirements;
|
||||
delete formattedValues.objectives;
|
||||
delete formattedValues.sections;
|
||||
try {
|
||||
if (editId) {
|
||||
await update.mutateAsync({
|
||||
where: { id: editId },
|
||||
data: {
|
||||
...data
|
||||
}
|
||||
})
|
||||
toast.success('课程更新成功!');
|
||||
data: formattedValues,
|
||||
});
|
||||
message.success("课程更新成功!");
|
||||
} else {
|
||||
const result = await create.mutateAsync({
|
||||
const result = await createCourse.mutateAsync({
|
||||
courseDetail: {
|
||||
data: {
|
||||
status: CourseStatus.DRAFT,
|
||||
...data
|
||||
title: formattedValues.title || "12345",
|
||||
state: CourseStatus.DRAFT,
|
||||
type: PostType.COURSE,
|
||||
...formattedValues,
|
||||
},
|
||||
},
|
||||
sections,
|
||||
});
|
||||
navigate(`/course/${result.id}/editor`, { replace: true });
|
||||
message.success("课程创建成功!");
|
||||
}
|
||||
})
|
||||
navigate(`/course/${result.id}/editor`, { replace: true })
|
||||
toast.success('课程创建成功!');
|
||||
}
|
||||
methods.reset(data);
|
||||
|
||||
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
console.error('Error submitting form:', error);
|
||||
toast.error('操作失败,请重试!');
|
||||
console.error("Error submitting form:", error);
|
||||
message.error("操作失败,请重试!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CourseEditorContext.Provider value={{ onSubmit, editId, course }}>
|
||||
<FormProvider {...methods}>
|
||||
<CourseEditorContext.Provider
|
||||
value={{
|
||||
onSubmit,
|
||||
editId,
|
||||
course,
|
||||
taxonomies,
|
||||
form,
|
||||
}}>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={onSubmit}
|
||||
initialValues={{
|
||||
requirements: [],
|
||||
objectives: [],
|
||||
}}>
|
||||
{children}
|
||||
</FormProvider>
|
||||
</Form>
|
||||
</CourseEditorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,24 +1,63 @@
|
|||
import { SubmitHandler, useFormContext } from 'react-hook-form';
|
||||
import { CourseLevel, CourseLevelLabel } from '@nice/common';
|
||||
import { FormInput } from '@web/src/components/common/form/FormInput';
|
||||
import { FormSelect } from '@web/src/components/common/form/FormSelect';
|
||||
import { FormArrayField } from '@web/src/components/common/form/FormArrayField';
|
||||
import { convertToOptions } from '@nice/client';
|
||||
import { CourseFormData } from '../context/CourseEditorContext';
|
||||
import QuillEditor from '@web/src/components/common/editor/quill/QuillEditor';
|
||||
import { FormQuillInput } from '@web/src/components/common/form/FormQuillInput';
|
||||
import { Form, Input, Select } from "antd";
|
||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||
import { convertToOptions } from "@nice/client";
|
||||
import TermSelect from "../../../term/term-select";
|
||||
import { useCourseEditor } from "../context/CourseEditorContext";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export function CourseBasicForm() {
|
||||
const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
|
||||
// 将 CourseLevelLabel 转换为 Ant Design Select 需要的选项格式
|
||||
const levelOptions = Object.entries(CourseLevelLabel).map(
|
||||
([key, value]) => ({
|
||||
label: value,
|
||||
value: key as CourseLevel,
|
||||
})
|
||||
);
|
||||
const { form, taxonomies } = useCourseEditor();
|
||||
return (
|
||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<FormInput viewMode maxLength={20} name="title" label="课程标题" placeholder="请输入课程标题" />
|
||||
<FormInput maxLength={10} name="subTitle" label="课程副标题" placeholder="请输入课程副标题" />
|
||||
<FormQuillInput maxLength={400} name="description" label="课程描述" placeholder="请输入课程描述"></FormQuillInput>
|
||||
<div className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="课程标题"
|
||||
rules={[
|
||||
{ required: true, message: "请输入课程标题" },
|
||||
{ max: 20, message: "标题最多20个字符" },
|
||||
]}>
|
||||
<Input placeholder="请输入课程标题" />
|
||||
</Form.Item>
|
||||
|
||||
<FormSelect name='level' label='难度等级' options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||
<Form.Item
|
||||
name="subTitle"
|
||||
label="课程副标题"
|
||||
rules={[{ max: 10, message: "副标题最多10个字符" }]}>
|
||||
<Input placeholder="请输入课程副标题" />
|
||||
</Form.Item>
|
||||
|
||||
</form>
|
||||
<Form.Item name="content" label="课程描述">
|
||||
<TextArea
|
||||
placeholder="请输入课程描述"
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
{taxonomies &&
|
||||
taxonomies.map((tax, index) => (
|
||||
<Form.Item
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
message: "",
|
||||
},
|
||||
]}
|
||||
label={tax.name}
|
||||
name={tax.name}
|
||||
key={index}>
|
||||
<TermSelect taxonomyId={tax.id}></TermSelect>
|
||||
</Form.Item>
|
||||
))}
|
||||
{/* <Form.Item name="level" label="难度等级">
|
||||
<Select placeholder="请选择难度等级" options={levelOptions} />
|
||||
</Form.Item> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,604 @@
|
|||
import {
|
||||
PlusOutlined,
|
||||
DragOutlined,
|
||||
DeleteOutlined,
|
||||
CaretRightOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Form,
|
||||
Alert,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Collapse,
|
||||
message,
|
||||
} from "antd";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { api, emitDataChange } from "@nice/client";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import QuillEditor from "../../../../common/editor/quill/QuillEditor";
|
||||
import { TusUploader } from "../../../../common/uploader/TusUploader";
|
||||
import { Lecture, LectureType, PostType } from "@nice/common";
|
||||
import { useCourseEditor } from "../context/CourseEditorContext";
|
||||
import { usePost } from "@nice/client";
|
||||
import toast from "react-hot-toast";
|
||||
interface SectionData {
|
||||
id: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
courseId?: string;
|
||||
}
|
||||
|
||||
interface LectureData {
|
||||
id: string;
|
||||
title: string;
|
||||
meta?: {
|
||||
type?: LectureType;
|
||||
fieldIds?: [];
|
||||
};
|
||||
content?: string;
|
||||
sectionId?: string;
|
||||
}
|
||||
const CourseContentFormHeader = () => (
|
||||
<Alert
|
||||
type="info"
|
||||
message="创建您的课程大纲"
|
||||
description={
|
||||
<>
|
||||
<p>通过组织清晰的章节和课时,帮助学员更好地学习。建议:</p>
|
||||
<ul className="mt-2 list-disc list-inside">
|
||||
<li>将相关内容组织到章节中</li>
|
||||
<li>每个章节建议包含 3-7 个课时</li>
|
||||
<li>课时可以是视频、文章或测验</li>
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
className="mb-8"
|
||||
/>
|
||||
);
|
||||
|
||||
const CourseSectionEmpty = () => (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed">
|
||||
<div className="text-gray-500">
|
||||
<PlusOutlined className="text-4xl mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">开始创建您的课程内容</h3>
|
||||
<p className="text-sm">点击下方按钮添加第一个章节</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface SortableSectionProps {
|
||||
courseId?: string;
|
||||
field: SectionData;
|
||||
remove: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SortableSection: React.FC<SortableSectionProps> = ({
|
||||
field,
|
||||
remove,
|
||||
courseId,
|
||||
children,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: field?.id });
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const [editing, setEditing] = useState(field.id ? false : true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { create, update } = usePost();
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!courseId) {
|
||||
toast.error("课程未创建,请先填写课程基本信息完成创建");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const values = await form.validateFields();
|
||||
let result;
|
||||
try {
|
||||
if (!field?.id) {
|
||||
result = await create.mutateAsync({
|
||||
data: {
|
||||
title: values?.title,
|
||||
type: PostType.SECTION,
|
||||
parentId: courseId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
result = await update.mutateAsync({
|
||||
data: {
|
||||
title: values?.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
field.id = result.id;
|
||||
setEditing(false);
|
||||
message.success("保存成功");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
message.error("保存失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
backgroundColor: isDragging ? "#f5f5f5" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="mb-4">
|
||||
<Collapse>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
editing ? (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex items-center gap-4">
|
||||
<Form.Item
|
||||
name="title"
|
||||
className="mb-0 flex-1"
|
||||
initialValue={field?.title}>
|
||||
<Input placeholder="章节标题" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
icon={<SaveOutlined />}
|
||||
type="primary">
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
if (!field?.id) {
|
||||
remove();
|
||||
}
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<Space>
|
||||
<DragOutlined
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-move"
|
||||
/>
|
||||
<span>{field.title || "未命名章节"}</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" danger onClick={remove}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
key={field.id || "new"}>
|
||||
{children}
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SortableLectureProps {
|
||||
field: LectureData;
|
||||
remove: () => void;
|
||||
sectionFieldKey: string;
|
||||
}
|
||||
|
||||
const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||
field,
|
||||
remove,
|
||||
sectionFieldKey,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: field?.id });
|
||||
const { create, update } = usePost();
|
||||
const [form] = Form.useForm();
|
||||
const [editing, setEditing] = useState(field?.id ? false : true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const lectureType =
|
||||
Form.useWatch(["meta", "type"], form) || LectureType.ARTICLE;
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const values = await form.validateFields();
|
||||
let result;
|
||||
try {
|
||||
if (!field.id) {
|
||||
result = await create.mutateAsync({
|
||||
data: {
|
||||
parentId: sectionFieldKey,
|
||||
type: PostType.LECTURE,
|
||||
title: values?.title,
|
||||
meta: {
|
||||
type: values?.meta?.type,
|
||||
fileIds: values?.meta?.fileIds,
|
||||
},
|
||||
resources: {
|
||||
connect: (values?.meta?.fileIds || []).map(
|
||||
(fileId) => ({
|
||||
fileId,
|
||||
})
|
||||
),
|
||||
},
|
||||
content: values?.content,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
result = await update.mutateAsync({
|
||||
where: {
|
||||
id: field?.id,
|
||||
},
|
||||
data: {
|
||||
title: values?.title,
|
||||
meta: {
|
||||
type: values?.meta?.type,
|
||||
fieldIds: values?.meta?.fileIds,
|
||||
},
|
||||
resources: {
|
||||
connect: (values?.meta?.fileIds || []).map(
|
||||
(fileId) => ({
|
||||
fileId,
|
||||
})
|
||||
),
|
||||
},
|
||||
content: values?.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
field.id = result.id;
|
||||
setEditing(false);
|
||||
message.success("保存成功");
|
||||
} catch (error) {
|
||||
message.error("保存失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
backgroundColor: isDragging ? "#f5f5f5" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="p-4">
|
||||
{editing ? (
|
||||
<Form form={form} initialValues={field}>
|
||||
<div className="flex gap-4">
|
||||
<Form.Item
|
||||
name="title"
|
||||
initialValue={field?.title}
|
||||
className="mb-0 flex-1"
|
||||
rules={[{ required: true }]}>
|
||||
<Input placeholder="课时标题" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["meta", "type"]}
|
||||
className="mb-0 w-32"
|
||||
rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder="选择类型"
|
||||
options={[
|
||||
{ label: "视频", value: LectureType.VIDEO },
|
||||
{
|
||||
label: "文章",
|
||||
value: LectureType.ARTICLE,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-1 ">
|
||||
{lectureType === LectureType.VIDEO ? (
|
||||
<Form.Item
|
||||
name={["meta", "fileIds"]}
|
||||
className="mb-0 flex-1"
|
||||
rules={[{ required: true }]}>
|
||||
<TusUploader multiple={false} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="content"
|
||||
className="mb-0 flex-1"
|
||||
rules={[{ required: true }]}>
|
||||
<QuillEditor />
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
if (!field?.id) {
|
||||
remove();
|
||||
}
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<Space>
|
||||
<DragOutlined
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-move"
|
||||
/>
|
||||
<CaretRightOutlined />
|
||||
<span>{field?.title || "未命名课时"}</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button type="link" onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" danger onClick={remove}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface LectureListProps {
|
||||
field: SectionData;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
const LectureList: React.FC<LectureListProps> = ({ field, sectionId }) => {
|
||||
const { softDeleteByIds } = usePost();
|
||||
const { data: lectures = [], isLoading } = (
|
||||
api.post.findMany as any
|
||||
).useQuery(
|
||||
{
|
||||
where: {
|
||||
parentId: sectionId,
|
||||
type: PostType.LECTURE,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!sectionId,
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
if (lectures && !isLoading) {
|
||||
setItems(lectures);
|
||||
}
|
||||
}, [lectures, isLoading]);
|
||||
const [items, setItems] = useState<LectureData[]>(lectures);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
setItems((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pl-8">
|
||||
<div
|
||||
onClick={() => {
|
||||
console.log(lectures);
|
||||
}}>
|
||||
123
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={items}
|
||||
strategy={verticalListSortingStrategy}>
|
||||
{items.map((lecture) => (
|
||||
<SortableLecture
|
||||
key={lecture.id}
|
||||
field={lecture}
|
||||
remove={async () => {
|
||||
if (lecture?.id) {
|
||||
await softDeleteByIds.mutateAsync({
|
||||
ids: [lecture.id],
|
||||
});
|
||||
}
|
||||
setItems(lectures);
|
||||
}}
|
||||
sectionFieldKey={sectionId}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
setItems([
|
||||
...items.filter((item) => !!item.id),
|
||||
{
|
||||
id: null,
|
||||
title: "",
|
||||
meta: {
|
||||
type: LectureType.ARTICLE,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}>
|
||||
添加课时
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CourseContentForm: React.FC = () => {
|
||||
const { editId } = useCourseEditor();
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
const { softDeleteByIds } = usePost();
|
||||
const { data: sections = [], isLoading } = api.post.findMany.useQuery(
|
||||
{
|
||||
where: {
|
||||
parentId: editId,
|
||||
type: PostType.SECTION,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!editId,
|
||||
}
|
||||
);
|
||||
|
||||
const [items, setItems] = useState<any[]>(sections);
|
||||
useEffect(() => {
|
||||
if (sections && !isLoading) {
|
||||
setItems(sections);
|
||||
}
|
||||
}, [sections]);
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
setItems((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<CourseContentFormHeader />
|
||||
|
||||
{items.length === 0 ? (
|
||||
<CourseSectionEmpty />
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={items}
|
||||
strategy={verticalListSortingStrategy}>
|
||||
{items?.map((section, index) => (
|
||||
<SortableSection
|
||||
courseId={editId}
|
||||
key={section.id}
|
||||
field={section}
|
||||
remove={async () => {
|
||||
if (section?.id) {
|
||||
await softDeleteByIds.mutateAsync({
|
||||
ids: [section.id],
|
||||
});
|
||||
}
|
||||
setItems(sections);
|
||||
}}>
|
||||
<LectureList
|
||||
field={section}
|
||||
sectionId={section.id}
|
||||
/>
|
||||
</SortableSection>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
setItems([
|
||||
...items.filter((item) => !!item.id),
|
||||
{ id: null, title: "" },
|
||||
]);
|
||||
}}>
|
||||
添加章节
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseContentForm;
|
|
@ -1,64 +0,0 @@
|
|||
import { Button } from "@web/src/components/common/element/Button";
|
||||
import { useState } from "react";
|
||||
import { PlusIcon, } from "@heroicons/react/24/outline";
|
||||
import { Section, UUIDGenerator } from "@nice/common";
|
||||
import SectionFormList from "./SectionFormList";
|
||||
|
||||
const CourseContentFormHeader = () =>
|
||||
<div className="mb-8 bg-blue-50 p-4 rounded-lg">
|
||||
<h2 className="text-xl font-semibold text-blue-800 mb-2">创建您的课程大纲</h2>
|
||||
<p className="text-blue-600">
|
||||
通过组织清晰的章节和课时,帮助学员更好地学习。建议:
|
||||
</p>
|
||||
<ul className="mt-2 text-blue-600 list-disc list-inside">
|
||||
<li>将相关内容组织到章节中</li>
|
||||
<li>每个章节建议包含 3-7 个课时</li>
|
||||
<li>课时可以是视频、文章或测验</li>
|
||||
</ul>
|
||||
</div>
|
||||
const CourseSectionEmpty = () => (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed">
|
||||
<div className="text-gray-500">
|
||||
<PlusIcon className="mx-auto h-12 w-12 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">开始创建您的课程内容</h3>
|
||||
<p className="text-sm">点击下方按钮添加第一个章节</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default function CourseContentForm() {
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
const addSection = () => {
|
||||
setSections(prev => [...prev, {
|
||||
id: UUIDGenerator.generate(),
|
||||
title: '新章节',
|
||||
lectures: []
|
||||
}]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<CourseContentFormHeader />
|
||||
<div className="space-y-4">
|
||||
{sections.length === 0 ? (
|
||||
<CourseSectionEmpty />
|
||||
) : (
|
||||
<SectionFormList
|
||||
sections={sections}
|
||||
setSections={setSections}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
fullWidth
|
||||
size="lg"
|
||||
rounded="xl"
|
||||
onClick={addSection}
|
||||
leftIcon={<PlusIcon></PlusIcon>}
|
||||
className="mt-4 bg-blue-500 hover:bg-blue-600 text-white"
|
||||
>
|
||||
添加新章节
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { Button } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { api } from "@nice/client";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { PostType } from "@nice/common";
|
||||
import { useCourseEditor } from "../../context/CourseEditorContext";
|
||||
import { usePost } from "@nice/client";
|
||||
import { CourseContentFormHeader } from "./CourseContentFormHeader";
|
||||
import { CourseSectionEmpty } from "./CourseSectionEmpty";
|
||||
import { SortableSection } from "./SortableSection";
|
||||
import { LectureList } from "./LectureList";
|
||||
|
||||
const CourseContentForm: React.FC = () => {
|
||||
const { editId } = useCourseEditor();
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
const { softDeleteByIds } = usePost();
|
||||
const { data: sections = [], isLoading } = api.post.findMany.useQuery(
|
||||
{
|
||||
where: {
|
||||
parentId: editId,
|
||||
type: PostType.SECTION,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!editId,
|
||||
}
|
||||
);
|
||||
|
||||
const [items, setItems] = useState<any[]>(sections);
|
||||
useEffect(() => {
|
||||
if (sections && !isLoading) {
|
||||
setItems(sections);
|
||||
}
|
||||
}, [sections]);
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
setItems((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<CourseContentFormHeader />
|
||||
|
||||
{items.length === 0 ? (
|
||||
<CourseSectionEmpty />
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={items}
|
||||
strategy={verticalListSortingStrategy}>
|
||||
{items?.map((section, index) => (
|
||||
<SortableSection
|
||||
courseId={editId}
|
||||
key={section.id}
|
||||
field={section}
|
||||
remove={async () => {
|
||||
if (section?.id) {
|
||||
await softDeleteByIds.mutateAsync({
|
||||
ids: [section.id],
|
||||
});
|
||||
}
|
||||
setItems(sections);
|
||||
}}>
|
||||
<LectureList
|
||||
field={section}
|
||||
sectionId={section.id}
|
||||
/>
|
||||
</SortableSection>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
setItems([
|
||||
...items.filter((item) => !!item.id),
|
||||
{ id: null, title: "" },
|
||||
]);
|
||||
}}>
|
||||
添加章节
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseContentForm;
|
|
@ -0,0 +1,35 @@
|
|||
import {
|
||||
PlusOutlined,
|
||||
DragOutlined,
|
||||
DeleteOutlined,
|
||||
CaretRightOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Form,
|
||||
Alert,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Collapse,
|
||||
message,
|
||||
} from "antd";
|
||||
|
||||
export const CourseContentFormHeader = () => (
|
||||
<Alert
|
||||
type="info"
|
||||
message="创建您的课程大纲"
|
||||
description={
|
||||
<>
|
||||
<p>通过组织清晰的章节和课时,帮助学员更好地学习。建议:</p>
|
||||
<ul className="mt-2 list-disc list-inside">
|
||||
<li>将相关内容组织到章节中</li>
|
||||
<li>每个章节建议包含 3-7 个课时</li>
|
||||
<li>课时可以是视频、文章或测验</li>
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
className="mb-8"
|
||||
/>
|
||||
);
|
|
@ -0,0 +1,11 @@
|
|||
import { PlusOutlined } from "@ant-design/icons";
|
||||
|
||||
export const CourseSectionEmpty = () => (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed">
|
||||
<div className="text-gray-500">
|
||||
<PlusOutlined className="text-4xl mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">开始创建您的课程内容</h3>
|
||||
<p className="text-sm">点击下方按钮添加第一个章节</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,150 @@
|
|||
import {
|
||||
PlusOutlined,
|
||||
DragOutlined,
|
||||
DeleteOutlined,
|
||||
CaretRightOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Form,
|
||||
Alert,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Collapse,
|
||||
message,
|
||||
} from "antd";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { api, emitDataChange } from "@nice/client";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
|
||||
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
|
||||
import { Lecture, LectureType, PostType } from "@nice/common";
|
||||
import { useCourseEditor } from "../../context/CourseEditorContext";
|
||||
import { usePost } from "@nice/client";
|
||||
import toast from "react-hot-toast";
|
||||
import { CourseContentFormHeader } from "./CourseContentFormHeader";
|
||||
import { CourseSectionEmpty } from "./CourseSectionEmpty";
|
||||
import { LectureData, SectionData } from "./interface";
|
||||
import { SortableLecture } from "./SortableLecture";
|
||||
|
||||
interface LectureListProps {
|
||||
field: SectionData;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
export const LectureList: React.FC<LectureListProps> = ({
|
||||
field,
|
||||
sectionId,
|
||||
}) => {
|
||||
const { softDeleteByIds } = usePost();
|
||||
const { data: lectures = [], isLoading } = (
|
||||
api.post.findMany as any
|
||||
).useQuery(
|
||||
{
|
||||
where: {
|
||||
parentId: sectionId,
|
||||
type: PostType.LECTURE,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!sectionId,
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
if (lectures && !isLoading) {
|
||||
setItems(lectures);
|
||||
}
|
||||
}, [lectures, isLoading]);
|
||||
const [items, setItems] = useState<LectureData[]>(lectures);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
setItems((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pl-8">
|
||||
<div
|
||||
onClick={() => {
|
||||
console.log(lectures);
|
||||
}}>
|
||||
123
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={items}
|
||||
strategy={verticalListSortingStrategy}>
|
||||
{items.map((lecture) => (
|
||||
<SortableLecture
|
||||
key={lecture.id}
|
||||
field={lecture}
|
||||
remove={async () => {
|
||||
if (lecture?.id) {
|
||||
await softDeleteByIds.mutateAsync({
|
||||
ids: [lecture.id],
|
||||
});
|
||||
}
|
||||
setItems(lectures);
|
||||
}}
|
||||
sectionFieldKey={sectionId}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
setItems([
|
||||
...items.filter((item) => !!item.id),
|
||||
{
|
||||
id: null,
|
||||
title: "",
|
||||
meta: {
|
||||
type: LectureType.ARTICLE,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}>
|
||||
添加课时
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,196 @@
|
|||
import {
|
||||
DragOutlined,
|
||||
CaretRightOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Form, Button, Input, Select, Space } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
|
||||
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
|
||||
import { LectureType, LessonTypeLabel, PostType } from "@nice/common";
|
||||
import { usePost } from "@nice/client";
|
||||
import toast from "react-hot-toast";
|
||||
import { LectureData } from "./interface";
|
||||
|
||||
interface SortableLectureProps {
|
||||
field: LectureData;
|
||||
remove: () => void;
|
||||
sectionFieldKey: string;
|
||||
}
|
||||
export const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||
field,
|
||||
remove,
|
||||
sectionFieldKey,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: field?.id });
|
||||
const { create, update } = usePost();
|
||||
const [form] = Form.useForm();
|
||||
const [editing, setEditing] = useState(field?.id ? false : true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const lectureType =
|
||||
Form.useWatch(["meta", "type"], form) || LectureType.ARTICLE;
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const values = await form.validateFields();
|
||||
let result;
|
||||
|
||||
if (!field.id) {
|
||||
result = await create.mutateAsync({
|
||||
data: {
|
||||
parentId: sectionFieldKey,
|
||||
type: PostType.LECTURE,
|
||||
title: values?.title,
|
||||
meta: {
|
||||
type: values?.meta?.type,
|
||||
fileIds: values?.meta?.fileIds,
|
||||
},
|
||||
resources: {
|
||||
connect: (values?.meta?.fileIds || []).map(
|
||||
(fileId) => ({
|
||||
fileId,
|
||||
})
|
||||
),
|
||||
},
|
||||
content: values?.content,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
result = await update.mutateAsync({
|
||||
where: {
|
||||
id: field?.id,
|
||||
},
|
||||
data: {
|
||||
title: values?.title,
|
||||
meta: {
|
||||
type: values?.meta?.type,
|
||||
fieldIds: values?.meta?.fileIds,
|
||||
},
|
||||
resources: {
|
||||
connect: (values?.meta?.fileIds || []).map(
|
||||
(fileId) => ({
|
||||
fileId,
|
||||
})
|
||||
),
|
||||
},
|
||||
content: values?.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
toast.success("课时已更新");
|
||||
field.id = result.id;
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
toast.success("更新失败");
|
||||
console.log(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
backgroundColor: isDragging ? "#f5f5f5" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="p-4">
|
||||
{editing ? (
|
||||
<Form form={form} initialValues={field}>
|
||||
<div className="flex gap-4">
|
||||
<Form.Item
|
||||
name="title"
|
||||
initialValue={field?.title}
|
||||
className="mb-0 flex-1"
|
||||
rules={[{ required: true }]}>
|
||||
<Input placeholder="课时标题" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["meta", "type"]}
|
||||
className="mb-0 w-32"
|
||||
rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder="选择类型"
|
||||
options={[
|
||||
{ label: "视频", value: LectureType.VIDEO },
|
||||
{
|
||||
label: "文章",
|
||||
value: LectureType.ARTICLE,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-1 ">
|
||||
{lectureType === LectureType.VIDEO ? (
|
||||
<Form.Item
|
||||
name={["meta", "fileIds"]}
|
||||
className="mb-0 flex-1"
|
||||
rules={[{ required: true }]}>
|
||||
<TusUploader multiple={false} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="content"
|
||||
className="mb-0 flex-1"
|
||||
rules={[{ required: true }]}>
|
||||
<QuillEditor />
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
if (!field?.id) {
|
||||
remove();
|
||||
}
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<Space>
|
||||
<DragOutlined
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-move"
|
||||
/>
|
||||
<CaretRightOutlined />
|
||||
<span>{LessonTypeLabel[field?.meta?.type]}</span>
|
||||
<span>{field?.title || "未命名课时"}</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button type="link" onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" danger onClick={remove}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,182 @@
|
|||
import {
|
||||
PlusOutlined,
|
||||
DragOutlined,
|
||||
DeleteOutlined,
|
||||
CaretRightOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Form,
|
||||
Alert,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Collapse,
|
||||
message,
|
||||
} from "antd";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { api, emitDataChange } from "@nice/client";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
|
||||
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
|
||||
import { Lecture, LectureType, PostType } from "@nice/common";
|
||||
import { useCourseEditor } from "../../context/CourseEditorContext";
|
||||
import { usePost } from "@nice/client";
|
||||
import toast from "react-hot-toast";
|
||||
import { CourseContentFormHeader } from "./CourseContentFormHeader";
|
||||
import { CourseSectionEmpty } from "./CourseSectionEmpty";
|
||||
import { LectureData, SectionData } from "./interface";
|
||||
interface SortableSectionProps {
|
||||
courseId?: string;
|
||||
field: SectionData;
|
||||
remove: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SortableSection: React.FC<SortableSectionProps> = ({
|
||||
field,
|
||||
remove,
|
||||
courseId,
|
||||
children,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: field?.id });
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const [editing, setEditing] = useState(field.id ? false : true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { create, update } = usePost();
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!courseId) {
|
||||
toast.error("课程未创建,请先填写课程基本信息完成创建");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const values = await form.validateFields();
|
||||
let result;
|
||||
try {
|
||||
if (!field?.id) {
|
||||
result = await create.mutateAsync({
|
||||
data: {
|
||||
title: values?.title,
|
||||
type: PostType.SECTION,
|
||||
parentId: courseId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
result = await update.mutateAsync({
|
||||
data: {
|
||||
title: values?.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
field.id = result.id;
|
||||
setEditing(false);
|
||||
message.success("保存成功");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
message.error("保存失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
backgroundColor: isDragging ? "#f5f5f5" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="mb-4">
|
||||
<Collapse>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
editing ? (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex items-center gap-4">
|
||||
<Form.Item
|
||||
name="title"
|
||||
className="mb-0 flex-1"
|
||||
initialValue={field?.title}>
|
||||
<Input placeholder="章节标题" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
icon={<SaveOutlined />}
|
||||
type="primary">
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
if (!field?.id) {
|
||||
remove();
|
||||
}
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<Space>
|
||||
<DragOutlined
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-move"
|
||||
/>
|
||||
<span>{field.title || "未命名章节"}</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => setEditing(true)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" danger onClick={remove}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
key={field.id || "new"}>
|
||||
{children}
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
import { LectureType } from "@nice/common";
|
||||
|
||||
export interface SectionData {
|
||||
id: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
courseId?: string;
|
||||
}
|
||||
|
||||
export interface LectureData {
|
||||
id: string;
|
||||
title: string;
|
||||
meta?: {
|
||||
type?: LectureType;
|
||||
fieldIds?: [];
|
||||
};
|
||||
content?: string;
|
||||
sectionId?: string;
|
||||
}
|
|
@ -3,12 +3,18 @@ import { useFormContext } from "react-hook-form";
|
|||
import { CourseFormData } from "../context/CourseEditorContext";
|
||||
|
||||
export function CourseGoalForm() {
|
||||
const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
|
||||
// const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
|
||||
|
||||
return (
|
||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<FormArrayField name="requirements" label="前置要求" placeholder="添加要求"></FormArrayField>
|
||||
<FormArrayField name="objectives" label="学习目标" placeholder="添加目标"></FormArrayField>
|
||||
<FormArrayField
|
||||
name="requirements"
|
||||
label="前置要求"
|
||||
placeholder="添加要求"></FormArrayField>
|
||||
<FormArrayField
|
||||
name="objectives"
|
||||
label="学习目标"
|
||||
placeholder="添加目标"></FormArrayField>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -1,164 +0,0 @@
|
|||
import { VideoCameraIcon, DocumentTextIcon, QuestionMarkCircleIcon, Bars3Icon, PencilIcon, TrashIcon, CloudArrowUpIcon, CheckIcon, ChevronUpIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import { Lecture } from "@nice/common";
|
||||
import { Card } from "@web/src/components/common/container/Card";
|
||||
import { Button } from "@web/src/components/common/element/Button";
|
||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
import { FormQuillInput } from "@web/src/components/common/form/FormQuillInput";
|
||||
import FileUploader from "@web/src/components/common/uploader/FileUploader";
|
||||
import { useState } from "react";
|
||||
const LectureTypeIcon = ({ type }: { type: string }) => {
|
||||
const iconClass = "h-5 w-5";
|
||||
switch (type) {
|
||||
case 'video':
|
||||
return <VideoCameraIcon className={`${iconClass} text-blue-500`} />;
|
||||
case 'article':
|
||||
return <DocumentTextIcon className={`${iconClass} text-green-500`} />;
|
||||
case 'quiz':
|
||||
return <QuestionMarkCircleIcon className={`${iconClass} text-purple-500`} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
interface LectureHeaderProps {
|
||||
lecture: Lecture;
|
||||
index: number;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
export function LectureHeader({
|
||||
lecture,
|
||||
index,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onDelete
|
||||
}: LectureHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className="group/lecture flex items-center gap-4 justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
title={isExpanded ? "收起课时" : "展开课时"}
|
||||
leftIcon={<ChevronRightIcon
|
||||
className={`transform transition-transform duration-200 ease-in-out
|
||||
${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>}
|
||||
>
|
||||
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex-shrink-0">
|
||||
<LectureTypeIcon type={lecture.type} />
|
||||
</div>
|
||||
<span className="inline-flex items-center justify-center w-5 h-5
|
||||
text-xs rounded-full bg-blue-100 text-blue-500">
|
||||
{index + 1}
|
||||
</span>
|
||||
<FormInput viewMode name="title"></FormInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-1 opacity-0 group-hover/lecture:opacity-100
|
||||
transition-all duration-200 ease-in-out">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(lecture.id);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost-danger"
|
||||
leftIcon={<TrashIcon></TrashIcon>}
|
||||
title="删除课时"
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface LectureEditorProps {
|
||||
lecture: Lecture;
|
||||
onUpdate: (lecture: Lecture) => void;
|
||||
|
||||
}
|
||||
export function LectureEditor({ lecture, onUpdate }: LectureEditorProps) {
|
||||
const tabs = [
|
||||
{ key: 'video', icon: VideoCameraIcon, label: '视频' },
|
||||
{ key: 'article', icon: DocumentTextIcon, label: '文章' }
|
||||
];
|
||||
return (
|
||||
<div className=" pt-6 bg-white" >
|
||||
<div className="flex space-x-4 mb-6 px-6">
|
||||
{tabs.map(({ key, icon: Icon, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onUpdate({ ...lecture, type: key })}
|
||||
className={`flex items-center space-x-2 px-6 py-2.5 rounded-lg transition-all
|
||||
${lecture.type === key
|
||||
? 'bg-blue-50 text-blue-600 shadow-sm ring-1 ring-blue-100'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="font-medium">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6 flex flex-col gap-4">
|
||||
{lecture.type === 'video' && (
|
||||
<div className="relative">
|
||||
<FileUploader placeholder="点击或拖拽视频到这里上传" />
|
||||
</div>
|
||||
)}
|
||||
{lecture.type === 'article' && (
|
||||
<div>
|
||||
<FormQuillInput minRows={8} label="文章内容" name="content" />
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<FileUploader placeholder="点击或拖拽资源到这里上传" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LectureFormItemProps {
|
||||
lecture: Lecture;
|
||||
index: number;
|
||||
onUpdate: (lecture: Lecture) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function LectureFormItem(props: LectureFormItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const handleToggle = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
className="flex-1 group relative flex flex-col p-4 "
|
||||
>
|
||||
<LectureHeader
|
||||
{...props}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
{isExpanded && (
|
||||
<LectureEditor
|
||||
lecture={props.lecture}
|
||||
onUpdate={props.onUpdate}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,145 +0,0 @@
|
|||
import { Lecture } from "packages/common/dist";
|
||||
import { useCallback } from "react";
|
||||
import { LectureFormItem } from "./LectureFormItem";
|
||||
import { Bars3Icon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button } from "@web/src/components/common/element/Button";
|
||||
|
||||
interface LectureFormListProps {
|
||||
lectures: Lecture[];
|
||||
sectionId: string;
|
||||
onUpdate: (lectures: Lecture[]) => void;
|
||||
}
|
||||
|
||||
interface SortableItemProps {
|
||||
lecture: Lecture;
|
||||
index: number;
|
||||
onUpdate: (lecture: Lecture) => void;
|
||||
onDelete: (lectureId: string) => void;
|
||||
}
|
||||
|
||||
function SortableItem({ lecture, index, onUpdate, onDelete }: SortableItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: lecture.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
|
||||
className={`flex items-center relative ${isDragging ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<LectureFormItem
|
||||
lecture={lecture}
|
||||
index={index}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
leftIcon={
|
||||
|
||||
<Bars3Icon
|
||||
/>
|
||||
} variant="ghost" className="absolute -left-8 cursor-grab active:cursor-grabbing " title="拖拽排序"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LectureFormList({ lectures, sectionId, onUpdate }: LectureFormListProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback((updatedLecture: Lecture) => {
|
||||
onUpdate(lectures.map(l => l.id === updatedLecture.id ? updatedLecture : l));
|
||||
}, [lectures, onUpdate]);
|
||||
|
||||
const handleDelete = useCallback((lectureId: string) => {
|
||||
onUpdate(lectures.filter(l => l.id !== lectureId));
|
||||
}, [lectures, onUpdate]);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = lectures.findIndex((lecture) => lecture.id === active.id);
|
||||
const newIndex = lectures.findIndex((lecture) => lecture.id === over.id);
|
||||
const newLectures = [...lectures];
|
||||
const [removed] = newLectures.splice(oldIndex, 1);
|
||||
newLectures.splice(newIndex, 0, removed);
|
||||
onUpdate(newLectures);
|
||||
}
|
||||
};
|
||||
|
||||
if (lectures.length === 0) {
|
||||
return (
|
||||
<div className="select-none flex items-center justify-center text-gray-500">
|
||||
暂无课程内容
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={lectures.map(lecture => lecture.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{lectures.map((lecture, index) => (
|
||||
<SortableItem
|
||||
key={lecture.id}
|
||||
lecture={lecture}
|
||||
index={index}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
|
@ -1,110 +0,0 @@
|
|||
import {
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
ChevronDownIcon
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Section, Lecture } from "@nice/common";
|
||||
import { useState } from "react";
|
||||
import { LectureFormList } from "./LectureFormList";
|
||||
import { cn } from "@web/src/utils/classname";
|
||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
import { Button } from "@web/src/components/common/element/Button";
|
||||
import { Card } from "@web/src/components/common/container/Card";
|
||||
interface SectionProps {
|
||||
section: Section;
|
||||
index: number;
|
||||
onUpdate: (section: Section) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function SectionFormItem({ section, index, onUpdate, onDelete }: SectionProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const handleAddLecture = () => {
|
||||
const newLecture: Lecture = {
|
||||
id: Date.now().toString(),
|
||||
title: '新课时',
|
||||
type: 'video'
|
||||
};
|
||||
onUpdate({
|
||||
...section,
|
||||
lectures: [...section.lectures, newLecture]
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="group/section relative flex-1 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
title={isCollapsed ? "展开章节" : "收起章节"}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<ChevronDownIcon
|
||||
className={cn(
|
||||
"transition-transform duration-300 ease-out",
|
||||
isCollapsed ? "-rotate-90" : "rotate-0"
|
||||
)}
|
||||
/>}
|
||||
>
|
||||
|
||||
</Button>
|
||||
<span className="inline-flex items-center justify-center w-7 h-7
|
||||
bg-blue-50 text-blue-600 text-sm font-semibold rounded">
|
||||
{index + 1}
|
||||
</span>
|
||||
<FormInput viewMode name="title"></FormInput>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 opacity-0 group-hover/section:opacity-100
|
||||
transition-all duration-200 ease-in-out">
|
||||
<Button
|
||||
onClick={() => onDelete(section.id)}
|
||||
variant="ghost-danger"
|
||||
size="sm"
|
||||
aria-label="删除章节"
|
||||
title="删除章节"
|
||||
leftIcon={<TrashIcon />}
|
||||
>
|
||||
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-all duration-300 ease-out",
|
||||
isCollapsed
|
||||
? "grid-rows-[0fr] opacity-0 invisible"
|
||||
: "grid-rows-[1fr] opacity-100 visible"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"overflow-hidden transition-all duration-300",
|
||||
isCollapsed ? "hidden" : "px-10 py-4"
|
||||
)}>
|
||||
<div className="space-y-4">
|
||||
<LectureFormList
|
||||
lectures={section.lectures}
|
||||
sectionId={section.id}
|
||||
onUpdate={(updatedLectures) =>
|
||||
onUpdate({ ...section, lectures: updatedLectures })}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleAddLecture}
|
||||
size="md"
|
||||
fullWidth
|
||||
variant="soft-primary"
|
||||
leftIcon={<PlusIcon></PlusIcon>}
|
||||
>
|
||||
|
||||
<span className="font-medium">添加课时</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,141 +0,0 @@
|
|||
import { Section } from "@nice/common";
|
||||
import { SectionFormItem } from "./SectionFormItem";
|
||||
import { Bars3Icon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button } from "@web/src/components/common/element/Button";
|
||||
|
||||
interface SectionFormListProps {
|
||||
sections: Section[];
|
||||
setSections: React.Dispatch<React.SetStateAction<Section[]>>;
|
||||
}
|
||||
|
||||
interface SortableItemProps {
|
||||
section: Section;
|
||||
index: number;
|
||||
onUpdate: (section: Section) => void;
|
||||
onDelete: (sectionId: string) => void;
|
||||
}
|
||||
|
||||
function SortableItem({ section, index, onUpdate, onDelete }: SortableItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: section.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center relative ${isDragging ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<SectionFormItem
|
||||
key={section.id}
|
||||
section={section}
|
||||
index={index}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={
|
||||
|
||||
<Bars3Icon
|
||||
/>
|
||||
} variant="ghost" className="absolute -right-10 cursor-grab active:cursor-grabbing " title="拖拽排序"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
></Button>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SectionFormList({ sections, setSections }: SectionFormListProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8, // 8px的移动距离后才开始拖拽
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const updateSection = (updatedSection: Section) => {
|
||||
setSections(prev => prev.map(section =>
|
||||
section.id === updatedSection.id ? updatedSection : section
|
||||
));
|
||||
};
|
||||
|
||||
const deleteSection = (sectionId: string) => {
|
||||
setSections(prev => prev.filter(section => section.id !== sectionId));
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setSections((prev) => {
|
||||
const oldIndex = prev.findIndex((section) => section.id === active.id);
|
||||
const newIndex = prev.findIndex((section) => section.id === over.id);
|
||||
|
||||
const newSections = [...prev];
|
||||
const [removed] = newSections.splice(oldIndex, 1);
|
||||
newSections.splice(newIndex, 0, removed);
|
||||
|
||||
return newSections;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sections.map(section => section.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{sections.map((section, index) => (
|
||||
<SortableItem
|
||||
key={section.id}
|
||||
section={section}
|
||||
index={index}
|
||||
onUpdate={updateSection}
|
||||
onDelete={deleteSection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
|
@ -1,49 +1,73 @@
|
|||
import { ArrowLeftIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||
import { SubmitHandler, useFormContext } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@web/src/components/common/element/Button';
|
||||
import { CourseStatus, CourseStatusLabel } from '@nice/common';
|
||||
import Tag from '@web/src/components/common/element/Tag';
|
||||
import { CourseFormData, useCourseEditor } from '../context/CourseEditorContext';
|
||||
import { ArrowLeftOutlined, ClockCircleOutlined } from "@ant-design/icons";
|
||||
import { Button, Tag, Typography } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CourseStatus, CourseStatusLabel } from "@nice/common";
|
||||
import { useCourseEditor } from "../context/CourseEditorContext";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const courseStatusVariant: Record<CourseStatus, string> = {
|
||||
[CourseStatus.DRAFT]: 'default',
|
||||
[CourseStatus.UNDER_REVIEW]: 'warning',
|
||||
[CourseStatus.PUBLISHED]: 'success',
|
||||
[CourseStatus.ARCHIVED]: 'danger'
|
||||
[CourseStatus.DRAFT]: "default",
|
||||
[CourseStatus.UNDER_REVIEW]: "warning",
|
||||
[CourseStatus.PUBLISHED]: "success",
|
||||
[CourseStatus.ARCHIVED]: "error",
|
||||
};
|
||||
|
||||
export default function CourseEditorHeader() {
|
||||
const navigate = useNavigate();
|
||||
const { onSubmit, course, form } = useCourseEditor();
|
||||
|
||||
const { handleSubmit, formState: { isValid, isDirty, errors } } = useFormContext<CourseFormData>()
|
||||
const handleSave = () => {
|
||||
try {
|
||||
form.validateFields().then((values) => {
|
||||
onSubmit(values);
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
const { onSubmit, course } = useCourseEditor()
|
||||
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
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
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>
|
||||
type="text"
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h2 className="font-medium text-gray-900">{course?.title || '新建课程'}</h2>
|
||||
<Tag variant={courseStatusVariant[course?.status || CourseStatus.DRAFT]}>
|
||||
{course?.status ? CourseStatusLabel[course.status] : CourseStatusLabel[CourseStatus.DRAFT]}
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{course?.title || "新建课程"}
|
||||
</Title>
|
||||
<Tag
|
||||
color={
|
||||
courseStatusVariant[
|
||||
course?.state || CourseStatus.DRAFT
|
||||
]
|
||||
}>
|
||||
{course?.state
|
||||
? CourseStatusLabel[course.state]
|
||||
: CourseStatusLabel[CourseStatus.DRAFT]}
|
||||
</Tag>
|
||||
{course?.totalDuration ? (
|
||||
<div className="hidden md:flex items-center text-gray-500 text-sm">
|
||||
<ClockIcon className="w-4 h-4 mr-1" />
|
||||
<span>总时长 {course?.totalDuration}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{course?.duration && (
|
||||
<span className="hidden md:flex items-center text-gray-500 text-sm">
|
||||
<ClockCircleOutlined
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
总时长 {course.duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={course ? (!isValid || !isDirty) : !isValid}
|
||||
size="sm"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={handleSave}
|
||||
// disabled={form
|
||||
// .getFieldsError()
|
||||
// .some(({ errors }) => errors.length)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { NavItem } from "@nice/client"
|
||||
import { motion } from "framer-motion";
|
||||
import { NavItem } from "@nice/client";
|
||||
import { useCourseEditor } from "../context/CourseEditorContext";
|
||||
interface CourseSidebarProps {
|
||||
id?: string | undefined;
|
||||
isHovered: boolean;
|
||||
setIsHovered: (value: boolean) => void;
|
||||
navItems: NavItem[];
|
||||
navItems: (NavItem & { isInitialized?: boolean; isCompleted?: boolean })[];
|
||||
selectedSection: number;
|
||||
onNavigate: (item: NavItem, index: number) => void;
|
||||
onNavigate: (
|
||||
item: NavItem & { isInitialized?: boolean; isCompleted?: boolean },
|
||||
index: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function CourseEditorSidebar({
|
||||
|
@ -13,8 +18,9 @@ export default function CourseEditorSidebar({
|
|||
setIsHovered,
|
||||
navItems,
|
||||
selectedSection,
|
||||
onNavigate
|
||||
onNavigate,
|
||||
}: CourseSidebarProps) {
|
||||
const { editId } = useCourseEditor();
|
||||
return (
|
||||
<motion.nav
|
||||
initial={{ width: "5rem" }}
|
||||
|
@ -22,29 +28,31 @@ export default function CourseEditorSidebar({
|
|||
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"
|
||||
>
|
||||
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
|
||||
type="button"
|
||||
disabled={!editId && !item.isInitialized}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
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"
|
||||
>
|
||||
className="ml-3 font-medium flex-1 truncate">
|
||||
{item.label}
|
||||
</motion.span>
|
||||
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||
|
||||
import { CourseFormData } from "../../context/CourseEditorContext";
|
||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
import { FormSelect } from "@web/src/components/common/form/FormSelect";
|
||||
import { convertToOptions } from "@nice/client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function CourseBasicForm() {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
handleSubmit,
|
||||
} = useFormContext<CourseFormData>();
|
||||
// useEffect(() => {
|
||||
// console.log(watch("audiences"));
|
||||
// }, [watch("audiences")]);
|
||||
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={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||
|
||||
import { CourseFormData } from "../../context/CourseEditorContext";
|
||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
import { FormSelect } from "@web/src/components/common/form/FormSelect";
|
||||
import { convertToOptions } from "@nice/client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function CourseContentForm() {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
handleSubmit,
|
||||
} = useFormContext<CourseFormData>();
|
||||
// useEffect(() => {
|
||||
// console.log(watch("audiences"));
|
||||
// }, [watch("audiences")]);
|
||||
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={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
import { useContext } from "react";
|
||||
import { useCourseEditor } from "../../context/CourseEditorContext";
|
||||
import { CoursePart } from "../enum";
|
||||
import { CourseBasicForm } from "./CourseBasicForm";
|
||||
import { CourseTargetForm } from "./CourseTargetForm";
|
||||
import { CourseContentForm } from "./CourseContentForm";
|
||||
|
||||
export default function CourseForm() {
|
||||
const { part } = useCourseEditor();
|
||||
if (part === CoursePart.OVERVIEW) {
|
||||
return <CourseBasicForm></CourseBasicForm>;
|
||||
}
|
||||
if (part === CoursePart.TARGET) {
|
||||
return <CourseTargetForm></CourseTargetForm>;
|
||||
}
|
||||
if (part === CoursePart.CONTENT) {
|
||||
return <CourseContentForm></CourseContentForm>;
|
||||
}
|
||||
if (part === CoursePart.SETTING) {
|
||||
return <></>;
|
||||
}
|
||||
return <CourseBasicForm></CourseBasicForm>;
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||
|
||||
import { CourseFormData } from "../../context/CourseEditorContext";
|
||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||
import { FormSelect } from "@web/src/components/common/form/FormSelect";
|
||||
import { convertToOptions } from "@nice/client";
|
||||
|
||||
export function CourseContentForm() {
|
||||
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={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||
|
||||
import { CourseFormData } from "../../context/CourseEditorContext";
|
||||
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||
import { convertToOptions } from "@nice/client";
|
||||
import { FormDynamicInputs } from "@web/src/components/common/form/FormDynamicInputs";
|
||||
|
||||
export function CourseTargetForm() {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
handleSubmit,
|
||||
} = useFormContext<CourseFormData>();
|
||||
|
||||
return (
|
||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||
<FormDynamicInputs
|
||||
name="objectives"
|
||||
label="本课的具体学习目标是什么?"
|
||||
// subTitle="学员在完成您的课程后期望掌握的技能"
|
||||
addTitle="目标"></FormDynamicInputs>
|
||||
<FormDynamicInputs
|
||||
name="skills"
|
||||
label="学生将从您的课程中学到什么技能?"
|
||||
subTitle="学员在完成您的课程后期望掌握的技能"
|
||||
addTitle="技能"></FormDynamicInputs>
|
||||
<FormDynamicInputs
|
||||
name="requirements"
|
||||
label="参加课程的要求或基本要求是什么?"
|
||||
subTitle="列出学员在参加课程之前应具备的所需技能、经验、工具或设备。
|
||||
如果没有要求,则可利用此空间作为降低初学者门槛的机会。"
|
||||
addTitle="要求"></FormDynamicInputs>
|
||||
<FormDynamicInputs
|
||||
name="audiences"
|
||||
subTitle="撰写您的课程目标学员的清晰描述,让学员了解您的课程内容很有价值。这将帮助您吸引合适的学员加入您的课程。"
|
||||
addTitle="目标受众"
|
||||
label="此课程的受众是谁?"></FormDynamicInputs>
|
||||
|
||||
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -1,25 +1,36 @@
|
|||
import { AcademicCapIcon, BookOpenIcon, Cog6ToothIcon, VideoCameraIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
BookOpenIcon,
|
||||
Cog6ToothIcon,
|
||||
VideoCameraIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { NavItem } from "@nice/client";
|
||||
|
||||
export const getNavItems = (courseId?: string): (NavItem & { isCompleted?: boolean })[] => [
|
||||
export const getNavItems = (
|
||||
courseId?: string
|
||||
): (NavItem & { isInitialized?: boolean; isCompleted?: boolean })[] => [
|
||||
{
|
||||
label: "课程概述",
|
||||
icon: <BookOpenIcon className="w-5 h-5" />,
|
||||
path: `/course/${courseId ? `${courseId}/` : ''}editor`
|
||||
path: `/course/${courseId ? `${courseId}/` : ""}editor`,
|
||||
isInitialized: true,
|
||||
},
|
||||
{
|
||||
label: "目标学员",
|
||||
icon: <AcademicCapIcon className="w-5 h-5" />,
|
||||
path: `/course/${courseId ? `${courseId}/` : ''}editor/goal`
|
||||
path: `/course/${courseId ? `${courseId}/` : ""}editor/goal`,
|
||||
isInitialized: false,
|
||||
},
|
||||
{
|
||||
label: "课程内容",
|
||||
icon: <VideoCameraIcon className="w-5 h-5" />,
|
||||
path: `/course/${courseId ? `${courseId}/` : ''}editor/content`
|
||||
path: `/course/${courseId ? `${courseId}/` : ""}editor/content`,
|
||||
isInitialized: false,
|
||||
},
|
||||
{
|
||||
label: "课程设置",
|
||||
icon: <Cog6ToothIcon className="w-5 h-5" />,
|
||||
path: `/course/${courseId ? `${courseId}/` : ''}editor/setting`
|
||||
path: `/course/${courseId ? `${courseId}/` : ""}editor/setting`,
|
||||
isInitialized: false,
|
||||
},
|
||||
];
|
|
@ -1,40 +0,0 @@
|
|||
import React from "react";
|
||||
import { useTusUpload } from "@web/src/hooks/useTusUpload"; // 假设 useTusUpload 在同一个目录下
|
||||
import * as tus from "tus-js-client";
|
||||
interface TusUploadProps {
|
||||
onSuccess?: (upload: tus.Upload) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export const TusUploader: React.FC<TusUploadProps> = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}) => {
|
||||
const { progress, isUploading, uploadError, handleFileUpload } =
|
||||
useTusUpload();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileUpload(file, onSuccess, onError);
|
||||
}}
|
||||
/>
|
||||
|
||||
{isUploading && (
|
||||
<div>
|
||||
<progress value={progress} max="100" />
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<div style={{ color: "red" }}>上传错误: {uploadError}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TusUploader;
|
|
@ -2,7 +2,7 @@ export const env: {
|
|||
APP_NAME: string;
|
||||
SERVER_IP: string;
|
||||
VERSION: string;
|
||||
UOLOAD_PORT: string;
|
||||
UPLOAD_PORT: string;
|
||||
SERVER_PORT: string;
|
||||
} = {
|
||||
APP_NAME: import.meta.env.PROD
|
||||
|
@ -11,9 +11,9 @@ export const env: {
|
|||
SERVER_IP: import.meta.env.PROD
|
||||
? (window as any).env.VITE_APP_SERVER_IP
|
||||
: import.meta.env.VITE_APP_SERVER_IP,
|
||||
UOLOAD_PORT: import.meta.env.PROD
|
||||
? (window as any).env.VITE_APP_UOLOAD_PORT
|
||||
: import.meta.env.VITE_APP_UOLOAD_PORT,
|
||||
UPLOAD_PORT: import.meta.env.PROD
|
||||
? (window as any).env.VITE_APP_UPLOAD_PORT
|
||||
: import.meta.env.VITE_APP_UPLOAD_PORT,
|
||||
SERVER_PORT: import.meta.env.PROD
|
||||
? (window as any).env.VITE_APP_SERVER_PORT
|
||||
: import.meta.env.VITE_APP_SERVER_PORT,
|
||||
|
|
|
@ -35,7 +35,8 @@ export function useTusUpload() {
|
|||
if (uploadIndex === -1 || uploadIndex + 4 >= parts.length) {
|
||||
throw new Error("Invalid upload URL format");
|
||||
}
|
||||
const resUrl = `http://${env.SERVER_IP}:${env.UOLOAD_PORT}/uploads/${parts.slice(uploadIndex + 1, uploadIndex + 6).join("/")}`;
|
||||
console.log(env.UPLOAD_PORT);
|
||||
const resUrl = `http://${env.SERVER_IP}:${env.UPLOAD_PORT}/uploads/${parts.slice(uploadIndex + 1, uploadIndex + 6).join("/")}`;
|
||||
|
||||
return resUrl;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
import { RolePerms } from "@nice/common";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
TagsOutlined,
|
||||
SafetyOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import BaseSettingPage from "../app/admin/base-setting/page";
|
||||
import DepartmentAdminPage from "../app/admin/department/page";
|
||||
import RoleAdminPage from "../app/admin/role/page";
|
||||
import TermAdminPage from "../app/admin/term/page";
|
||||
import WithAuth from "../components/utils/with-auth";
|
||||
import { CustomRouteObject } from "./types";
|
||||
import StaffPage from "../app/admin/staff/page";
|
||||
import AdminLayout from "../components/layout/admin/AdminLayout";
|
||||
|
||||
export const adminRoute: CustomRouteObject = {
|
||||
path: "admin",
|
||||
name: "系统设置",
|
||||
element: <AdminLayout></AdminLayout>,
|
||||
children: [
|
||||
{
|
||||
path: "base-setting",
|
||||
name: "基本设置",
|
||||
icon: <SettingOutlined />,
|
||||
element: (
|
||||
<WithAuth
|
||||
options={{
|
||||
orPermissions: [RolePerms.MANAGE_BASE_SETTING],
|
||||
}}>
|
||||
<BaseSettingPage></BaseSettingPage>
|
||||
</WithAuth>
|
||||
),
|
||||
handle: {
|
||||
crumb() {
|
||||
return <Link to={"/admin/base-setting"}>基本设置</Link>;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "department",
|
||||
name: "组织架构",
|
||||
icon: <TeamOutlined />,
|
||||
element: (
|
||||
<WithAuth
|
||||
options={{
|
||||
orPermissions: [RolePerms.MANAGE_ANY_DEPT],
|
||||
}}>
|
||||
<DepartmentAdminPage></DepartmentAdminPage>
|
||||
</WithAuth>
|
||||
),
|
||||
handle: {
|
||||
crumb() {
|
||||
return <Link to={"/admin/department"}>组织架构</Link>;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "staff",
|
||||
name: "用户管理",
|
||||
icon: <UserOutlined />,
|
||||
element: (
|
||||
<WithAuth
|
||||
options={{
|
||||
orPermissions: [
|
||||
RolePerms.MANAGE_ANY_STAFF,
|
||||
RolePerms.MANAGE_DOM_STAFF,
|
||||
],
|
||||
}}>
|
||||
<StaffPage></StaffPage>
|
||||
</WithAuth>
|
||||
),
|
||||
handle: {
|
||||
crumb() {
|
||||
return <Link to={"/admin/staff"}>用户管理</Link>;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "term",
|
||||
name: "分类配置",
|
||||
icon: <TagsOutlined />,
|
||||
element: (
|
||||
<WithAuth
|
||||
options={{
|
||||
orPermissions: [
|
||||
RolePerms.MANAGE_ANY_TERM,
|
||||
// RolePerms.MANAGE_DOM_TERM
|
||||
],
|
||||
}}>
|
||||
<TermAdminPage></TermAdminPage>
|
||||
</WithAuth>
|
||||
),
|
||||
handle: {
|
||||
crumb() {
|
||||
return <Link to={"/admin/term"}>分类配置</Link>;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "role",
|
||||
name: "角色管理",
|
||||
icon: <SafetyOutlined />,
|
||||
element: (
|
||||
<WithAuth
|
||||
options={{
|
||||
orPermissions: [
|
||||
RolePerms.MANAGE_ANY_ROLE,
|
||||
RolePerms.MANAGE_DOM_ROLE,
|
||||
],
|
||||
}}>
|
||||
<RoleAdminPage></RoleAdminPage>
|
||||
</WithAuth>
|
||||
),
|
||||
handle: {
|
||||
crumb() {
|
||||
return <Link to={"/admin/role"}>角色管理</Link>;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
|
@ -4,28 +4,22 @@ import {
|
|||
Link,
|
||||
NonIndexRouteObject,
|
||||
} from "react-router-dom";
|
||||
import { RolePerms } from "@nice/common";
|
||||
import ErrorPage from "../app/error";
|
||||
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 WithAuth from "../components/utils/with-auth";
|
||||
import LoginPage from "../app/login";
|
||||
import BaseSettingPage from "../app/admin/base-setting/page";
|
||||
import StudentCoursesPage from "../app/main/courses/student/page";
|
||||
import InstructorCoursesPage from "../app/main/courses/instructor/page";
|
||||
import HomePage from "../app/main/home/page";
|
||||
import { CourseDetailPage } from "../app/main/course/detail/page";
|
||||
import { CourseBasicForm } from "../components/models/course/editor/form/CourseBasicForm";
|
||||
import CourseContentForm from "../components/models/course/editor/form/CourseContentForm";
|
||||
import CourseContentForm from "../components/models/course/editor/form/CourseContentForm/CourseContentForm";
|
||||
import { CourseGoalForm } from "../components/models/course/editor/form/CourseGoalForm";
|
||||
import CourseSettingForm from "../components/models/course/editor/form/CourseSettingForm";
|
||||
import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout";
|
||||
import { MainLayout } from "../app/main/layout/MainLayout";
|
||||
import CoursesPage from "../app/main/courses/page";
|
||||
import PathsPage from "../app/main/paths/page";
|
||||
|
||||
import { adminRoute } from "./admin-route";
|
||||
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||
name?: string;
|
||||
breadcrumb?: string;
|
||||
|
@ -70,13 +64,13 @@ export const routes: CustomRouteObject[] = [
|
|||
},
|
||||
{
|
||||
path: "courses",
|
||||
element: <CoursesPage></CoursesPage>
|
||||
element: <CoursesPage></CoursesPage>,
|
||||
},
|
||||
{
|
||||
path: "my-courses"
|
||||
path: "my-courses",
|
||||
},
|
||||
{
|
||||
path: "profiles"
|
||||
path: "profiles",
|
||||
},
|
||||
{
|
||||
path: "courses",
|
||||
|
@ -107,23 +101,28 @@ export const routes: CustomRouteObject[] = [
|
|||
{
|
||||
path: ":id?/editor",
|
||||
element: <CourseEditorLayout></CourseEditorLayout>,
|
||||
children: [{
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <CourseBasicForm></CourseBasicForm>
|
||||
element: <CourseBasicForm></CourseBasicForm>,
|
||||
},
|
||||
{
|
||||
path: 'goal',
|
||||
element: <CourseGoalForm></CourseGoalForm>
|
||||
path: "goal",
|
||||
element: <CourseGoalForm></CourseGoalForm>,
|
||||
},
|
||||
{
|
||||
path: 'content',
|
||||
element: <CourseContentForm></CourseContentForm>
|
||||
path: "content",
|
||||
element: (
|
||||
<CourseContentForm></CourseContentForm>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'setting',
|
||||
element: <CourseSettingForm></CourseSettingForm>
|
||||
}
|
||||
]
|
||||
path: "setting",
|
||||
element: (
|
||||
<CourseSettingForm></CourseSettingForm>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: ":id?/detail", // 使用 ? 表示 id 参数是可选的
|
||||
|
@ -131,115 +130,7 @@ export const routes: CustomRouteObject[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
children: [
|
||||
{
|
||||
path: "base-setting",
|
||||
element: (
|
||||
<WithAuth
|
||||
options={{
|
||||
orPermissions: [
|
||||
RolePerms.MANAGE_BASE_SETTING,
|
||||
],
|
||||
}}>
|
||||
<BaseSettingPage></BaseSettingPage>
|
||||
</WithAuth>
|
||||
),
|
||||
handle: {
|
||||
crumb() {
|
||||
return (
|
||||
<Link to={"/admin/base-setting"}>
|
||||
基本设置
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "department",
|
||||
breadcrumb: "单位管理",
|
||||
element: (
|
||||
<WithAuth
|
||||
options={{
|
||||
orPermissions: [RolePerms.MANAGE_ANY_DEPT],
|
||||
}}>
|
||||
<DepartmentAdminPage></DepartmentAdminPage>
|
||||
</WithAuth>
|
||||
),
|
||||
handle: {
|
||||
crumb() {
|
||||
return (
|
||||
<Link to={"/admin/department"}>
|
||||
组织架构
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "staff",
|
||||
element: (
|
||||
<WithAuth
|
||||
options={{
|
||||
orPermissions: [
|
||||
RolePerms.MANAGE_ANY_STAFF,
|
||||
RolePerms.MANAGE_DOM_STAFF,
|
||||
],
|
||||
}}>
|
||||
<StaffAdminPage></StaffAdminPage>
|
||||
</WithAuth>
|
||||
),
|
||||
handle: {
|
||||
crumb() {
|
||||
return (
|
||||
<Link to={"/admin/staff"}>用户管理</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "term",
|
||||
breadcrumb: "分类配置",
|
||||
element: (
|
||||
<WithAuth
|
||||
options={{
|
||||
orPermissions: [
|
||||
RolePerms.MANAGE_ANY_TERM,
|
||||
// RolePerms.MANAGE_DOM_TERM
|
||||
],
|
||||
}}>
|
||||
<TermAdminPage></TermAdminPage>
|
||||
</WithAuth>
|
||||
),
|
||||
handle: {
|
||||
crumb() {
|
||||
return <Link to={"/admin/term"}>分类配置</Link>;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "role",
|
||||
breadcrumb: "角色管理",
|
||||
element: (
|
||||
<WithAuth
|
||||
options={{
|
||||
orPermissions: [
|
||||
RolePerms.MANAGE_ANY_ROLE,
|
||||
RolePerms.MANAGE_DOM_ROLE,
|
||||
],
|
||||
}}>
|
||||
<RoleAdminPage></RoleAdminPage>
|
||||
</WithAuth>
|
||||
),
|
||||
handle: {
|
||||
crumb() {
|
||||
return <Link to={"/admin/role"}>角色管理</Link>;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
adminRoute,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import { ReactNode } from "react";
|
||||
import { IndexRouteObject, NonIndexRouteObject } from "react-router-dom";
|
||||
|
||||
export interface CustomIndexRouteObject extends IndexRouteObject {
|
||||
name?: string;
|
||||
icon?: ReactNode
|
||||
}
|
||||
export interface CustomNonIndexRouteObject extends NonIndexRouteObject {
|
||||
name?: string;
|
||||
children?: CustomRouteObject[];
|
||||
icon?: ReactNode
|
||||
handle?: {
|
||||
crumb: (data?: any) => void;
|
||||
};
|
||||
}
|
||||
export type CustomRouteObject =
|
||||
| CustomIndexRouteObject
|
||||
| CustomNonIndexRouteObject;
|
|
@ -12,6 +12,7 @@
|
|||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"dev-static": "tsup --no-watch",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
|
|
@ -9,4 +9,4 @@ export * from "./useTaxonomy"
|
|||
export * from "./useVisitor"
|
||||
export * from "./useMessage"
|
||||
export * from "./usePost"
|
||||
export * from "./useCourse"
|
||||
// export * from "./useCourse"
|
|
@ -1,76 +1,76 @@
|
|||
import { api } from "../trpc";
|
||||
// import { api } from "../trpc";
|
||||
|
||||
// 定义返回类型
|
||||
type UseCourseReturn = {
|
||||
// Queries
|
||||
findMany: typeof api.course.findMany.useQuery;
|
||||
findFirst: typeof api.course.findFirst.useQuery;
|
||||
findManyWithCursor: typeof api.course.findManyWithCursor.useQuery;
|
||||
// // 定义返回类型
|
||||
// type UseCourseReturn = {
|
||||
// // Queries
|
||||
// findMany: typeof api.course.findMany.useQuery;
|
||||
// findFirst: typeof api.course.findFirst.useQuery;
|
||||
// findManyWithCursor: typeof api.course.findManyWithCursor.useQuery;
|
||||
|
||||
// Mutations
|
||||
create: ReturnType<any>;
|
||||
// create: ReturnType<typeof api.course.create.useMutation>;
|
||||
update: ReturnType<any>;
|
||||
// update: ReturnType<typeof api.course.update.useMutation>;
|
||||
createMany: ReturnType<typeof api.course.createMany.useMutation>;
|
||||
deleteMany: ReturnType<typeof api.course.deleteMany.useMutation>;
|
||||
softDeleteByIds: ReturnType<any>;
|
||||
// softDeleteByIds: ReturnType<typeof api.course.softDeleteByIds.useMutation>;
|
||||
updateOrder: ReturnType<any>;
|
||||
// updateOrder: ReturnType<typeof api.course.updateOrder.useMutation>;
|
||||
};
|
||||
// // Mutations
|
||||
// create: ReturnType<any>;
|
||||
// // create: ReturnType<typeof api.course.create.useMutation>;
|
||||
// update: ReturnType<any>;
|
||||
// // update: ReturnType<typeof api.course.update.useMutation>;
|
||||
// createMany: ReturnType<typeof api.course.createMany.useMutation>;
|
||||
// deleteMany: ReturnType<typeof api.course.deleteMany.useMutation>;
|
||||
// softDeleteByIds: ReturnType<any>;
|
||||
// // softDeleteByIds: ReturnType<typeof api.course.softDeleteByIds.useMutation>;
|
||||
// updateOrder: ReturnType<any>;
|
||||
// // updateOrder: ReturnType<typeof api.course.updateOrder.useMutation>;
|
||||
// };
|
||||
|
||||
export function useCourse(): UseCourseReturn {
|
||||
const utils = api.useUtils();
|
||||
return {
|
||||
// Queries
|
||||
findMany: api.course.findMany.useQuery,
|
||||
findFirst: api.course.findFirst.useQuery,
|
||||
findManyWithCursor: api.course.findManyWithCursor.useQuery,
|
||||
// export function useCourse(): UseCourseReturn {
|
||||
// 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.invalidate();
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate();
|
||||
},
|
||||
}),
|
||||
update: api.course.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate();
|
||||
},
|
||||
}),
|
||||
createMany: api.course.createMany.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate();
|
||||
},
|
||||
}),
|
||||
deleteMany: api.course.deleteMany.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate();
|
||||
},
|
||||
}),
|
||||
softDeleteByIds: api.course.softDeleteByIds.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate();
|
||||
},
|
||||
}),
|
||||
updateOrder: api.course.updateOrder.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate();
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
// // Mutations
|
||||
// create: api.course.create.useMutation({
|
||||
// onSuccess: () => {
|
||||
// utils.course.invalidate();
|
||||
// utils.course.findMany.invalidate();
|
||||
// utils.course.findManyWithCursor.invalidate();
|
||||
// utils.course.findManyWithPagination.invalidate();
|
||||
// },
|
||||
// }),
|
||||
// update: api.course.update.useMutation({
|
||||
// onSuccess: () => {
|
||||
// utils.course.findMany.invalidate();
|
||||
// utils.course.findManyWithCursor.invalidate();
|
||||
// utils.course.findManyWithPagination.invalidate();
|
||||
// },
|
||||
// }),
|
||||
// createMany: api.course.createMany.useMutation({
|
||||
// onSuccess: () => {
|
||||
// utils.course.findMany.invalidate();
|
||||
// utils.course.findManyWithCursor.invalidate();
|
||||
// utils.course.findManyWithPagination.invalidate();
|
||||
// },
|
||||
// }),
|
||||
// deleteMany: api.course.deleteMany.useMutation({
|
||||
// onSuccess: () => {
|
||||
// utils.course.findMany.invalidate();
|
||||
// utils.course.findManyWithCursor.invalidate();
|
||||
// utils.course.findManyWithPagination.invalidate();
|
||||
// },
|
||||
// }),
|
||||
// softDeleteByIds: api.course.softDeleteByIds.useMutation({
|
||||
// onSuccess: () => {
|
||||
// utils.course.findMany.invalidate();
|
||||
// utils.course.findManyWithCursor.invalidate();
|
||||
// utils.course.findManyWithPagination.invalidate();
|
||||
// },
|
||||
// }),
|
||||
// updateOrder: api.course.updateOrder.useMutation({
|
||||
// onSuccess: () => {
|
||||
// utils.course.findMany.invalidate();
|
||||
// utils.course.findManyWithCursor.invalidate();
|
||||
// utils.course.findManyWithPagination.invalidate();
|
||||
// },
|
||||
// }),
|
||||
// };
|
||||
// }
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import { DataNode, DepartmentDto, ObjectType } from "@nice/common";
|
||||
import { DataNode, DepartmentDto, ObjectType, TreeDataNode } from "@nice/common";
|
||||
import { api } from "../trpc";
|
||||
import { findQueryData, getCacheDataFromQuery } from "../utils";
|
||||
import { CrudOperation, emitDataChange } from "../../event";
|
||||
|
@ -10,34 +10,45 @@ export function useDepartment() {
|
|||
const create = api.department.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
emitDataChange(ObjectType.DEPARTMENT, result as any, CrudOperation.CREATED)
|
||||
emitDataChange(
|
||||
ObjectType.DEPARTMENT,
|
||||
result as any,
|
||||
CrudOperation.CREATED
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const update = api.department.update.useMutation({
|
||||
onSuccess: (result) => {
|
||||
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
emitDataChange(ObjectType.DEPARTMENT, result as any, CrudOperation.UPDATED)
|
||||
emitDataChange(
|
||||
ObjectType.DEPARTMENT,
|
||||
result as any,
|
||||
CrudOperation.UPDATED
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const softDeleteByIds = api.department.softDeleteByIds.useMutation({
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
emitDataChange(ObjectType.DEPARTMENT, result as any, CrudOperation.DELETED)
|
||||
emitDataChange(
|
||||
ObjectType.DEPARTMENT,
|
||||
result as any,
|
||||
CrudOperation.DELETED
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const buildTree = (
|
||||
data: DepartmentDto[],
|
||||
parentId: string | null = null
|
||||
): DataNode[] => {
|
||||
): TreeDataNode[] => {
|
||||
return data
|
||||
.filter((department) => department.parentId === parentId)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((department) => {
|
||||
const node: DataNode = {
|
||||
const node: TreeDataNode = {
|
||||
title: department.name,
|
||||
key: department.id,
|
||||
value: department.id,
|
||||
|
@ -58,6 +69,6 @@ export function useDepartment() {
|
|||
update,
|
||||
create,
|
||||
// getTreeData,
|
||||
getDept
|
||||
getDept,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,117 @@
|
|||
import type { UseTRPCMutationResult } from "@trpc/react-query/shared";
|
||||
import { api, type RouterInputs, type RouterOutputs } from "../trpc";
|
||||
/**
|
||||
* 定义 MutationType 类型,用于提取指定实体类型的特定 mutation 方法名。
|
||||
* @template T - 实体类型的键名(如 'post', 'user')
|
||||
* @description 该类型通过遍历 RouterInputs[T] 的键名,筛选出符合条件的 mutation 方法名(如 'create', 'update' 等)。
|
||||
*/
|
||||
type MutationType<T extends keyof RouterInputs> = keyof {
|
||||
[K in keyof RouterInputs[T]]: K extends
|
||||
| "create"
|
||||
| "update"
|
||||
| "deleteMany"
|
||||
| "softDeleteByIds"
|
||||
| "restoreByIds"
|
||||
| "updateOrder"
|
||||
? RouterInputs[T][K]
|
||||
: never;
|
||||
};
|
||||
/**
|
||||
* 定义 MutationOptions 类型,用于配置特定 mutation 方法的回调函数。
|
||||
* @template T - 实体类型的键名
|
||||
* @template K - mutation 方法名
|
||||
* @description 该类型包含一个可选的 onSuccess 回调函数,用于在 mutation 成功时执行。
|
||||
*/
|
||||
type MutationOptions<
|
||||
T extends keyof RouterInputs,
|
||||
K extends MutationType<T>,
|
||||
> = {
|
||||
onSuccess?: (
|
||||
data: RouterOutputs[T][K], // mutation 成功后的返回数据
|
||||
variables: RouterInputs[T][K], // mutation 的输入参数
|
||||
context?: unknown // 可选的上下文信息
|
||||
) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 定义 EntityOptions 类型,用于配置实体类型的所有 mutation 方法的回调函数。
|
||||
* @template T - 实体类型的键名
|
||||
* @description 该类型是一个对象,键为 mutation 方法名,值为对应的 MutationOptions 配置。
|
||||
*/
|
||||
type EntityOptions<T extends keyof RouterInputs> = {
|
||||
[K in MutationType<T>]?: MutationOptions<T, K>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 工具类型:简化 UseTRPCMutationResult 的类型断言。
|
||||
* @template T - 实体类型的键名
|
||||
* @template K - mutation 方法名
|
||||
* @description 该类型用于简化 UseTRPCMutationResult 的类型定义,使其更易读。
|
||||
*/
|
||||
export type MutationResult<
|
||||
T extends keyof RouterInputs,
|
||||
K extends MutationType<T>,
|
||||
> = UseTRPCMutationResult<
|
||||
RouterOutputs[T][K], // mutation 成功后的返回数据
|
||||
unknown, // mutation 的错误类型
|
||||
RouterInputs[T][K], // mutation 的输入参数
|
||||
unknown // mutation 的上下文类型
|
||||
>;
|
||||
|
||||
/**
|
||||
* 自定义 Hook:用于处理实体的 mutation 操作,并内置缓存失效机制。
|
||||
* @template T - 实体类型的键名(如 'post', 'user')
|
||||
* @param {T} key - 实体键名
|
||||
* @param {EntityOptions<T>} [options] - 可选的 mutation 回调函数配置
|
||||
* @returns 返回一个包含多个 mutation 函数的对象
|
||||
* @description 该 Hook 封装了常见的 mutation 操作(如 create, update, deleteMany 等),并在每次 mutation 成功后自动失效相关缓存。
|
||||
*/
|
||||
export function useEntity<T extends keyof RouterInputs>(
|
||||
key: T,
|
||||
options?: EntityOptions<T>
|
||||
) {
|
||||
const utils = api.useUtils(); // 获取 tRPC 的工具函数,用于操作缓存
|
||||
|
||||
/**
|
||||
* 创建 mutation 处理函数。
|
||||
* @template K - mutation 方法名
|
||||
* @param {K} mutation - mutation 方法名
|
||||
* @returns 返回一个配置好的 mutation 函数
|
||||
* @description 该函数根据传入的 mutation 方法名,生成对应的 mutation 函数,并配置 onSuccess 回调。
|
||||
*/
|
||||
|
||||
const createMutationHandler = <K extends MutationType<T>>(mutation: K) => {
|
||||
const mutationFn = api[key as any][mutation]; // 获取对应的 tRPC mutation 函数
|
||||
return mutationFn.useMutation({
|
||||
onSuccess: (data, variables, context) => {
|
||||
utils[key].invalidate(); // 失效指定实体的缓存
|
||||
options?.[mutation]?.onSuccess?.(data, variables, context); // 调用用户自定义的 onSuccess 回调
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 返回包含多个 mutation 函数的对象
|
||||
return {
|
||||
create: createMutationHandler("create") as MutationResult<T, "create">, // 创建实体的 mutation 函数
|
||||
createCourse: createMutationHandler("createCourse") as MutationResult<
|
||||
T,
|
||||
"createCourse"
|
||||
>, // 创建实体的 mutation 函数
|
||||
update: createMutationHandler("update") as MutationResult<T, "create">, // 更新实体的 mutation 函数
|
||||
deleteMany: createMutationHandler("deleteMany") as MutationResult<
|
||||
T,
|
||||
"deleteMany"
|
||||
>, // 批量删除实体的 mutation 函数
|
||||
softDeleteByIds: createMutationHandler(
|
||||
"softDeleteByIds"
|
||||
) as MutationResult<T, "softDeleteByIds">, // 软删除实体的 mutation 函数
|
||||
restoreByIds: createMutationHandler("restoreByIds") as MutationResult<
|
||||
T,
|
||||
"restoreByIds"
|
||||
>, // 恢复软删除实体的 mutation 函数
|
||||
updateOrder: createMutationHandler("updateOrder") as MutationResult<
|
||||
T,
|
||||
"updateOrder"
|
||||
>, // 更新实体顺序的 mutation 函数
|
||||
};
|
||||
}
|
|
@ -1,18 +1,5 @@
|
|||
import { api } from "../trpc";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import { Prisma } from "packages/common/dist";
|
||||
import { useEntity } from "./useEntity";
|
||||
|
||||
export function useMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = getQueryKey(api.message);
|
||||
const create:any = api.message.create.useMutation({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
create
|
||||
};
|
||||
return useEntity("message");
|
||||
}
|
|
@ -1,37 +1,12 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
import { MutationResult, useEntity } from "./useEntity";
|
||||
import { ObjectType } from "@nice/common";
|
||||
import { api } from "../trpc";
|
||||
|
||||
import { CrudOperation, emitDataChange } from "../../event";
|
||||
export function usePost() {
|
||||
const utils = api.useUtils();
|
||||
const create = api.post.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.post.invalidate();
|
||||
},
|
||||
});
|
||||
const update = api.post.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.post.invalidate();
|
||||
},
|
||||
});
|
||||
const deleteMany = api.post.deleteMany.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.post.invalidate();
|
||||
},
|
||||
});
|
||||
const softDeleteByIds = api.post.softDeleteByIds.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.post.invalidate();
|
||||
},
|
||||
});
|
||||
const restoreByIds = api.post.restoreByIds.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.post.invalidate();
|
||||
},
|
||||
})
|
||||
return {
|
||||
create,
|
||||
update,
|
||||
deleteMany,
|
||||
softDeleteByIds,
|
||||
restoreByIds
|
||||
};
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = getQueryKey(api.post);
|
||||
|
||||
return useEntity("post");
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { getQueryKey } from "@trpc/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DataNode, ObjectType, TermDto } from "@nice/common";
|
||||
import { DataNode, ObjectType, TermDto, TreeDataNode } from "@nice/common";
|
||||
import { api } from "../trpc";
|
||||
import { findQueryData } from "../utils";
|
||||
import { CrudOperation, emitDataChange } from "../../event";
|
||||
|
@ -37,12 +37,12 @@ export function useTerm() {
|
|||
const buildTree = (
|
||||
data: TermDto[],
|
||||
parentId: string | null = null
|
||||
): DataNode[] => {
|
||||
): TreeDataNode[] => {
|
||||
return data
|
||||
.filter((term) => term.parentId === parentId)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((term) => {
|
||||
const node: DataNode = {
|
||||
const node: TreeDataNode = {
|
||||
title: term.name,
|
||||
key: term.id,
|
||||
value: term.id,
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import { api } from "../trpc";
|
||||
import { TroubleParams } from "../../singleton/DataHolder";
|
||||
import { PostParams } from "../../singleton/DataHolder";
|
||||
|
||||
export function useVisitor() {
|
||||
const utils = api.useUtils();
|
||||
const troubleParams = TroubleParams.getInstance();
|
||||
const postParams = PostParams.getInstance();
|
||||
|
||||
const create = api.visitor.create.useMutation({
|
||||
onSuccess() {
|
||||
utils.visitor.invalidate();
|
||||
// utils.trouble.invalidate();
|
||||
|
||||
// utils.post.invalidate();
|
||||
},
|
||||
});
|
||||
/**
|
||||
|
@ -19,68 +20,81 @@ export function useVisitor() {
|
|||
const createOptimisticMutation = (
|
||||
updateFn: (item: any, variables: any) => any
|
||||
) => ({
|
||||
// 在请求发送前执行本地数据预更新
|
||||
// onMutate: async (variables: any) => {
|
||||
// const previousDataList: any[] = [];
|
||||
// // 动态生成参数列表,包括星标和其他参数
|
||||
//在请求发送前执行本地数据预更新
|
||||
onMutate: async (variables: any) => {
|
||||
const previousDataList: any[] = [];
|
||||
const previousDetailDataList: any[] = [];
|
||||
|
||||
// const paramsList = troubleParams.getItems();
|
||||
// console.log(paramsList.length);
|
||||
// // 遍历所有参数列表,执行乐观更新
|
||||
// for (const params of paramsList) {
|
||||
// // 取消可能的并发请求
|
||||
// await utils.trouble.findManyWithCursor.cancel();
|
||||
// // 获取并保存当前数据
|
||||
// const previousData =
|
||||
// utils.trouble.findManyWithCursor.getInfiniteData({
|
||||
// ...params,
|
||||
// });
|
||||
// previousDataList.push(previousData);
|
||||
// // 执行乐观更新
|
||||
// utils.trouble.findManyWithCursor.setInfiniteData(
|
||||
// {
|
||||
// ...params,
|
||||
// },
|
||||
// (oldData) => {
|
||||
// if (!oldData) return oldData;
|
||||
// return {
|
||||
// ...oldData,
|
||||
// pages: oldData.pages.map((page) => ({
|
||||
// ...page,
|
||||
// items: page.items.map((item) =>
|
||||
// item.id === variables?.troubleId
|
||||
// ? updateFn(item, variables)
|
||||
// : item
|
||||
// ),
|
||||
// })),
|
||||
// };
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
// 处理列表数据
|
||||
const paramsList = postParams.getItems();
|
||||
for (const params of paramsList) {
|
||||
await utils.post.findManyWithCursor.cancel();
|
||||
const previousData =
|
||||
utils.post.findManyWithCursor.getInfiniteData({
|
||||
...params,
|
||||
});
|
||||
previousDataList.push(previousData);
|
||||
utils.post.findManyWithCursor.setInfiniteData(
|
||||
{
|
||||
...params,
|
||||
},
|
||||
(oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
items: (page.items as any).map((item) =>
|
||||
item.id === variables?.postId
|
||||
? updateFn(item, variables)
|
||||
: item
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// return { previousDataList };
|
||||
// },
|
||||
// // 错误处理:数据回滚
|
||||
// onError: (_err: any, _variables: any, context: any) => {
|
||||
// const paramsList = troubleParams.getItems();
|
||||
// paramsList.forEach((params, index) => {
|
||||
// if (context?.previousDataList?.[index]) {
|
||||
// utils.trouble.findManyWithCursor.setInfiniteData(
|
||||
// { ...params },
|
||||
// context.previousDataList[index]
|
||||
// );
|
||||
// }
|
||||
// });
|
||||
// },
|
||||
// // 成功后的缓存失效
|
||||
// onSuccess: (_: any, variables: any) => {
|
||||
// utils.visitor.invalidate();
|
||||
// utils.trouble.findFirst.invalidate({
|
||||
// where: {
|
||||
// id: (variables as any)?.troubleId,
|
||||
// },
|
||||
// });
|
||||
// },
|
||||
// 处理详情数据
|
||||
const detailParamsList = postParams.getDetailItems();
|
||||
for (const params of detailParamsList) {
|
||||
await utils.post.findFirst.cancel();
|
||||
const previousDetailData = utils.post.findFirst.getData(params);
|
||||
previousDetailDataList.push(previousDetailData);
|
||||
utils.post.findFirst.setData(params, (oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.id === variables?.postId
|
||||
? updateFn(oldData, variables)
|
||||
: oldData;
|
||||
});
|
||||
}
|
||||
|
||||
return { previousDataList, previousDetailDataList };
|
||||
},
|
||||
// 错误处理:数据回滚
|
||||
onError: (_err: any, _variables: any, context: any) => {
|
||||
const paramsList = postParams.getItems();
|
||||
paramsList.forEach((params, index) => {
|
||||
if (context?.previousDataList?.[index]) {
|
||||
utils.post.findManyWithCursor.setInfiniteData(
|
||||
{ ...params },
|
||||
context.previousDataList[index]
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
// 成功后的缓存失效
|
||||
onSuccess: async (_: any, variables: any) => {
|
||||
await Promise.all([
|
||||
utils.visitor.invalidate(),
|
||||
utils.post.findFirst.invalidate({
|
||||
where: {
|
||||
id: (variables as any)?.postId,
|
||||
},
|
||||
}),
|
||||
utils.post.findManyWithCursor.invalidate(),
|
||||
]);
|
||||
},
|
||||
});
|
||||
// 定义具体的mutation
|
||||
const read = api.visitor.create.useMutation(
|
||||
|
@ -90,6 +104,35 @@ export function useVisitor() {
|
|||
readed: true,
|
||||
}))
|
||||
);
|
||||
const like = api.visitor.create.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
...item,
|
||||
likes: (item.likes || 0) + 1,
|
||||
liked: true,
|
||||
}))
|
||||
);
|
||||
const unLike = api.visitor.deleteMany.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
...item,
|
||||
likes: item.likes - 1 || 0,
|
||||
liked: false,
|
||||
}))
|
||||
);
|
||||
|
||||
const hate = api.visitor.create.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
...item,
|
||||
hates: (item.hates || 0) + 1,
|
||||
hated: true,
|
||||
}))
|
||||
);
|
||||
const unHate = api.visitor.deleteMany.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
...item,
|
||||
hates: item.hates - 1 || 0,
|
||||
hated: false,
|
||||
}))
|
||||
);
|
||||
|
||||
const addStar = api.visitor.create.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
|
@ -120,12 +163,16 @@ export function useVisitor() {
|
|||
});
|
||||
|
||||
return {
|
||||
troubleParams,
|
||||
postParams,
|
||||
create,
|
||||
createMany,
|
||||
deleteMany,
|
||||
read,
|
||||
addStar,
|
||||
deleteStar,
|
||||
like,
|
||||
unLike,
|
||||
hate,
|
||||
unHate,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,4 +1,11 @@
|
|||
import { createTRPCReact } from '@trpc/react-query';
|
||||
import type { AppRouter } from '@server/trpc/trpc.router';
|
||||
import type { AppRouter } from "@server/trpc/types";
|
||||
import {
|
||||
createTRPCReact,
|
||||
type inferReactQueryProcedureOptions,
|
||||
} from "@trpc/react-query";
|
||||
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
||||
export type ReactQueryOptions = inferReactQueryProcedureOptions<AppRouter>;
|
||||
export type RouterInputs = inferRouterInputs<AppRouter>;
|
||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
|
||||
export const api = createTRPCReact<AppRouter>();
|
||||
|
|
|
@ -7,4 +7,4 @@ export * from "./hooks"
|
|||
export * from "./websocket"
|
||||
export * from "./event"
|
||||
export * from "./types"
|
||||
export * from "./upload"
|
||||
// export * from "./upload"
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue