This commit is contained in:
longdayi 2025-02-19 16:08:18 +08:00
commit 2bd1941bd8
113 changed files with 6206 additions and 3497 deletions

View File

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

View File

@ -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,13 +13,15 @@ export class ExceptionsFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = exception instanceof HttpException
? exception.message
: 'Internal server error';
const message =
exception instanceof HttpException
? exception.message
: 'Internal server error';
response.status(status).json({
statusCode: status,

View File

@ -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,10 +497,9 @@ export class BaseService<
: undefined,
} as any)) as any[];
/**
*
* @description
* @description
* 1. take,
* 2. ,updatedAt和id构造下一页游标
* 3. 游标格式为: updatedAt_id
@ -502,7 +515,7 @@ export class BaseService<
/**
*
* @returns {Object}
* @returns {Object}
* - items: 当前页记录
* - totalCount: 总记录数
* - nextCursor: 下一页游标
@ -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

View File

@ -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.
@ -11,379 +17,400 @@ import { DataArgs, DelegateArgs, DelegateFuncs, DelegateReturnTypes, UpdateOrder
* @template R - Return types for the model delegate's operations.
*/
export class BaseTreeService<
D extends DelegateFuncs,
A extends DelegateArgs<D> = DelegateArgs<D>,
R extends DelegateReturnTypes<D> = DelegateReturnTypes<D>,
D extends DelegateFuncs,
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,
) {
super(prisma, objectType, enableOrder);
}
async getNextOrder(
transaction: any,
parentId: string | null,
parentOrder?: number,
): Promise<number> {
// 查找同层级最后一个节点的 order
const lastOrder = await transaction[this.objectType].findFirst({
where: {
parentId: parentId ?? null,
},
select: { order: true },
orderBy: { order: 'desc' },
} as any);
constructor(
protected prisma: PrismaClient,
protected objectType: string,
protected ancestryType: string = objectType + 'Ancestry',
protected enableOrder: boolean = false
) {
super(prisma, objectType, enableOrder)
}
async getNextOrder(
transaction: any,
parentId: string | null,
parentOrder?: number
): Promise<number> {
// 查找同层级最后一个节点的 order
const lastOrder = await transaction[this.objectType].findFirst({
where: {
parentId: parentId ?? null
},
// 如果有父节点
if (parentId) {
// 获取父节点的 order如果未提供
const parentNodeOrder =
parentOrder ??
(
await transaction[this.objectType].findUnique({
where: { id: parentId },
select: { order: true },
orderBy: { order: 'desc' },
} as any);
})
)?.order ??
0;
// 如果有父节点
if (parentId) {
// 获取父节点的 order如果未提供
const parentNodeOrder = parentOrder ?? (
await transaction[this.objectType].findUnique({
where: { id: parentId },
select: { order: true }
})
)?.order ?? 0;
// 如果存在最后一个同层级节点,确保新节点 order 大于最后一个节点
// 否则,新节点 order 设置为父节点 order + 1
return lastOrder
? Math.max(lastOrder.order + this.ORDER_INTERVAL,
parentNodeOrder + this.ORDER_INTERVAL)
: parentNodeOrder + this.ORDER_INTERVAL;
}
// 对于根节点,直接使用最后一个节点的 order + 1
return lastOrder?.order ? lastOrder?.order + this.ORDER_INTERVAL : 1;
// 如果存在最后一个同层级节点,确保新节点 order 大于最后一个节点
// 否则,新节点 order 设置为父节点 order + 1
return lastOrder
? Math.max(
lastOrder.order + this.ORDER_INTERVAL,
parentNodeOrder + this.ORDER_INTERVAL,
)
: parentNodeOrder + this.ORDER_INTERVAL;
}
async create(args: A['create']) {
const anyArgs = args as any
return this.prisma.$transaction(async (transaction) => {
if (this.enableOrder) {
// 获取新节点的 order
anyArgs.data.order = await this.getNextOrder(
transaction,
anyArgs?.data.parentId ?? null
);
}
// 创建节点
const result: any = await super.create(anyArgs, { tx: transaction });
// 对于根节点,直接使用最后一个节点的 order + 1
return lastOrder?.order ? lastOrder?.order + this.ORDER_INTERVAL : 1;
}
// 更新父节点的 hasChildren 状态
if (anyArgs.data.parentId) {
await transaction[this.objectType].update({
where: { id: anyArgs.data.parentId },
data: { hasChildren: true }
});
}
async create(args: A['create'], params?: any) {
const anyArgs = args as any;
// 如果传入了外部事务,直接使用该事务执行所有操作
// 如果没有外部事务,则创建新事务
const executor = async (transaction: any) => {
if (this.enableOrder) {
anyArgs.data.order = await this.getNextOrder(
transaction,
anyArgs?.data.parentId ?? null,
);
}
// 创建祖先关系
const newAncestries = anyArgs.data.parentId
? [
...(
await transaction[this.ancestryType].findMany({
where: { descendantId: anyArgs.data.parentId },
select: { ancestorId: true, relDepth: true },
})
).map(({ ancestorId, relDepth }) => ({
ancestorId,
descendantId: result.id,
relDepth: relDepth + 1,
})),
{
ancestorId: result.parentId,
descendantId: result.id,
relDepth: 1,
},
]
: [{ ancestorId: null, descendantId: result.id, relDepth: 1 }];
const result: any = await super.create(anyArgs, { tx: transaction });
await transaction[this.ancestryType].createMany({ data: newAncestries });
if (anyArgs.data.parentId) {
await transaction[this.objectType].update({
where: { id: anyArgs.data.parentId },
data: { hasChildren: true },
});
}
return result;
}) as Promise<R['create']>;
}
/**
* parentId更改时管理DeptAncestry关系
* @param data -
* @returns
*/
async update(args: A['update']) {
const anyArgs = args as any
return this.prisma.$transaction(async (transaction) => {
const current = await transaction[this.objectType].findUnique({
where: { id: anyArgs.where.id },
});
if (!current) throw new Error('object not found');
const result: any = await super.update(anyArgs, { tx: transaction });
if (anyArgs.data.parentId !== current.parentId) {
await transaction[this.ancestryType].deleteMany({
where: { descendantId: result.id },
});
// 更新原父级的 hasChildren 状态
if (current.parentId) {
const childrenCount = await transaction[this.objectType].count({
where: { parentId: current.parentId, deletedAt: null }
});
if (childrenCount === 0) {
await transaction[this.objectType].update({
where: { id: current.parentId },
data: { hasChildren: false }
});
}
}
if (anyArgs.data.parentId) {
await transaction[this.objectType].update({
where: { id: anyArgs.data.parentId },
data: { hasChildren: true }
});
const parentAncestries = await transaction[this.ancestryType].findMany({
where: { descendantId: anyArgs.data.parentId },
});
const newAncestries = parentAncestries.map(
({ ancestorId, relDepth }) => ({
ancestorId,
descendantId: result.id,
relDepth: relDepth + 1,
}),
);
newAncestries.push({
ancestorId: anyArgs.data.parentId,
descendantId: result.id,
relDepth: 1,
});
await transaction[this.ancestryType].createMany({ data: newAncestries });
} else {
await transaction[this.ancestryType].create({
data: { ancestorId: null, descendantId: result.id, relDepth: 0 },
});
}
}
return result;
}) as Promise<R['update']>;
}
/**
* Soft deletes records by setting `isDeleted` to true for the given IDs.
* @param ids - An array of IDs of the records to soft delete.
* @param data - Additional data to update on soft delete. (Optional)
* @returns {Promise<R['update'][]>} - A promise resolving to an array of updated records.
* @example
* const softDeletedUsers = await service.softDeleteByIds(['user_id1', 'user_id2'], { reason: 'Bulk deletion' });
*/
async softDeleteByIds(
ids: string[],
data: Partial<DataArgs<A['update']>> = {}, // Default to empty object
): Promise<R['update'][]> {
return this.prisma.$transaction(async tx => {
// 首先找出所有需要软删除的记录的父级ID
const parentIds = await tx[this.objectType].findMany({
where: {
id: { in: ids },
parentId: { not: null }
},
select: { parentId: true }
});
const uniqueParentIds = [...new Set(parentIds.map(p => p.parentId))];
// 执行软删除
const result = await super.softDeleteByIds(ids, data);
// 删除相关的祖先关系
await tx[this.ancestryType].deleteMany({
where: {
OR: [
{ ancestorId: { in: ids } },
{ descendantId: { in: ids } },
],
}
});
// 更新父级的 hasChildren 状态
if (uniqueParentIds.length > 0) {
for (const parentId of uniqueParentIds) {
const remainingChildrenCount = await tx[this.objectType].count({
where: {
parentId: parentId,
deletedAt: null
}
});
if (remainingChildrenCount === 0) {
await tx[this.objectType].update({
where: { id: parentId },
data: { hasChildren: false }
});
}
}
}
return result;
}) 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)
return this.prisma[this.ancestryType].findMany({
where: {
OR: [
{ ancestorId: { in: validIds } },
{ ancestorId: hasNull ? null : undefined },
]
const newAncestries = anyArgs.data.parentId
? [
...(
await transaction[this.ancestryType].findMany({
where: { descendantId: anyArgs.data.parentId },
select: { ancestorId: true, relDepth: true },
})
).map(({ ancestorId, relDepth }) => ({
ancestorId,
descendantId: result.id,
relDepth: relDepth + 1,
})),
{
ancestorId: result.parentId,
descendantId: result.id,
relDepth: 1,
},
});
}
]
: [{ ancestorId: null, descendantId: result.id, relDepth: 1 }];
getDescendants(ids: string[]) {
if (!ids || ids.length === 0) return [];
const validIds = ids.filter(id => id != null);
const hasNull = ids.includes(null)
return this.prisma[this.ancestryType].findMany({
await transaction[this.ancestryType].createMany({ data: newAncestries });
return result;
};
// 根据是否有外部事务决定执行方式
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'], 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 },
});
if (!current) throw new Error('object not found');
const result: any = await super.update(anyArgs, { tx: transaction });
if (anyArgs.data.parentId !== current.parentId) {
await transaction[this.ancestryType].deleteMany({
where: { descendantId: result.id },
});
// 更新原父级的 hasChildren 状态
if (current.parentId) {
const childrenCount = await transaction[this.objectType].count({
where: { parentId: current.parentId, deletedAt: null },
});
if (childrenCount === 0) {
await transaction[this.objectType].update({
where: { id: current.parentId },
data: { hasChildren: false },
});
}
}
if (anyArgs.data.parentId) {
await transaction[this.objectType].update({
where: { id: anyArgs.data.parentId },
data: { hasChildren: true },
});
const parentAncestries = await transaction[
this.ancestryType
].findMany({
where: { descendantId: anyArgs.data.parentId },
});
const newAncestries = parentAncestries.map(
({ ancestorId, relDepth }) => ({
ancestorId,
descendantId: result.id,
relDepth: relDepth + 1,
}),
);
newAncestries.push({
ancestorId: anyArgs.data.parentId,
descendantId: result.id,
relDepth: 1,
});
await transaction[this.ancestryType].createMany({
data: newAncestries,
});
} else {
await transaction[this.ancestryType].create({
data: { ancestorId: null, descendantId: result.id, relDepth: 0 },
});
}
}
return result;
}) as Promise<R['update']>;
}
/**
* Soft deletes records by setting `isDeleted` to true for the given IDs.
* @param ids - An array of IDs of the records to soft delete.
* @param data - Additional data to update on soft delete. (Optional)
* @returns {Promise<R['update'][]>} - A promise resolving to an array of updated records.
* @example
* const softDeletedUsers = await service.softDeleteByIds(['user_id1', 'user_id2'], { reason: 'Bulk deletion' });
*/
async softDeleteByIds(
ids: string[],
data: Partial<DataArgs<A['update']>> = {}, // Default to empty object
): Promise<R['update'][]> {
return this.prisma.$transaction(async (tx) => {
// 首先找出所有需要软删除的记录的父级ID
const parentIds = await tx[this.objectType].findMany({
where: {
id: { in: ids },
parentId: { not: null },
},
select: { parentId: true },
});
const uniqueParentIds = [...new Set(parentIds.map((p) => p.parentId))];
// 执行软删除
const result = await super.softDeleteByIds(ids, data);
// 删除相关的祖先关系
await tx[this.ancestryType].deleteMany({
where: {
OR: [{ ancestorId: { in: ids } }, { descendantId: { in: ids } }],
},
});
// 更新父级的 hasChildren 状态
if (uniqueParentIds.length > 0) {
for (const parentId of uniqueParentIds) {
const remainingChildrenCount = await tx[this.objectType].count({
where: {
OR: [
{ ancestorId: { in: validIds } },
{ ancestorId: hasNull ? null : undefined },
]
parentId: parentId,
deletedAt: null,
},
});
}
async getDescendantIds(ids: string | string[], includeOriginalIds: boolean = false): Promise<string[]> {
// 将单个 ID 转换为数组
const idArray = Array.isArray(ids) ? ids : [ids];
const res = await this.getDescendants(idArray);
const descendantSet = new Set(res?.map((item) => item.descendantId) || []);
if (includeOriginalIds) {
idArray.forEach(id => descendantSet.add(id));
});
if (remainingChildrenCount === 0) {
await tx[this.objectType].update({
where: { id: parentId },
data: { hasChildren: false },
});
}
}
}
return Array.from(descendantSet).filter(Boolean) as string[];
return result;
}) 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);
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);
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[]> {
// 将单个 ID 转换为数组
const idArray = Array.isArray(ids) ? ids : [ids];
const res = await this.getDescendants(idArray);
const descendantSet = new Set(res?.map((item) => item.descendantId) || []);
if (includeOriginalIds) {
idArray.forEach((id) => descendantSet.add(id));
}
async getAncestorIds(ids: string | string[], includeOriginalIds: boolean = false): Promise<string[]> {
// 将单个 ID 转换为数组
const idArray = Array.isArray(ids) ? ids : [ids];
return Array.from(descendantSet).filter(Boolean) as string[];
}
const res = await this.getDescendants(idArray);
const ancestorSet = new Set<string>();
async getAncestorIds(
ids: string | string[],
includeOriginalIds: boolean = false,
): Promise<string[]> {
// 将单个 ID 转换为数组
const idArray = Array.isArray(ids) ? ids : [ids];
// 按深度排序并添加祖先ID
res
?.sort((a, b) => b.relDepth - a.relDepth)
?.forEach((item) => ancestorSet.add(item.ancestorId));
const res = await this.getDescendants(idArray);
const ancestorSet = new Set<string>();
// 根据参数决定是否添加原始ID
if (includeOriginalIds) {
idArray.forEach((id) => ancestorSet.add(id));
}
// 按深度排序并添加祖先ID
res
?.sort((a, b) => b.relDepth - a.relDepth)
?.forEach((item) => ancestorSet.add(item.ancestorId));
return Array.from(ancestorSet).filter(Boolean) as string[];
}
async updateOrder(args: UpdateOrderArgs) {
const { id, overId } = args;
return this.prisma.$transaction(async (transaction) => {
// 查找当前节点和目标节点
const currentObject = await transaction[this.objectType].findUnique({
where: { id },
select: { id: true, parentId: true, order: true }
});
const targetObject = await transaction[this.objectType].findUnique({
where: { id: overId },
select: { id: true, parentId: true, order: true }
});
// 验证节点
if (!currentObject || !targetObject) {
throw new Error('Invalid object or target object');
}
// 查找父节点
const parentObject = currentObject.parentId
? await transaction[this.objectType].findUnique({
where: { id: currentObject.parentId },
select: { id: true, order: true }
})
: null;
// 确保在同一父节点下移动
if (currentObject.parentId !== targetObject.parentId) {
throw new Error('Cannot move between different parent nodes');
}
// 查找同层级的所有节点,按 order 排序
const siblingNodes = await transaction[this.objectType].findMany({
where: {
parentId: targetObject.parentId
},
select: { id: true, order: true },
orderBy: { order: 'asc' }
});
// 找到目标节点和当前节点在兄弟节点中的索引
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;
siblingNodes.splice(insertIndex, 0, currentObject);
// 重新分配 order
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 }
})
);
await Promise.all(updatePromises);
// 返回更新后的当前节点
return transaction[this.objectType].findUnique({
where: { id: currentObject.id }
});
});
// 根据参数决定是否添加原始ID
if (includeOriginalIds) {
idArray.forEach((id) => ancestorSet.add(id));
}
// 重新分配 order 的方法
private redistributeOrder(nodes: Array<{ id: string, order: number }>, parentOrder: number): number[] {
const MIN_CHILD_ORDER = parentOrder + this.ORDER_INTERVAL; // 子节点 order 必须大于父节点
const newOrders: number[] = [];
return Array.from(ancestorSet).filter(Boolean) as string[];
}
async updateOrder(args: UpdateOrderArgs) {
const { id, overId } = args;
nodes.forEach((_, index) => {
// 使用等差数列分配 order确保大于父节点
const nodeOrder = MIN_CHILD_ORDER + (index + 1) * this.ORDER_INTERVAL;
newOrders.push(nodeOrder);
});
return this.prisma.$transaction(async (transaction) => {
// 查找当前节点和目标节点
const currentObject = await transaction[this.objectType].findUnique({
where: { id },
select: { id: true, parentId: true, order: true },
});
return newOrders;
}
const targetObject = await transaction[this.objectType].findUnique({
where: { id: overId },
select: { id: true, parentId: true, order: true },
});
// 验证节点
if (!currentObject || !targetObject) {
throw new Error('Invalid object or target object');
}
// 查找父节点
const parentObject = currentObject.parentId
? await transaction[this.objectType].findUnique({
where: { id: currentObject.parentId },
select: { id: true, order: true },
})
: null;
}
// 确保在同一父节点下移动
if (currentObject.parentId !== targetObject.parentId) {
throw new Error('Cannot move between different parent nodes');
}
// 查找同层级的所有节点,按 order 排序
const siblingNodes = await transaction[this.objectType].findMany({
where: {
parentId: targetObject.parentId,
},
select: { id: true, order: true },
orderBy: { order: 'asc' },
});
// 找到目标节点和当前节点在兄弟节点中的索引
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;
siblingNodes.splice(insertIndex, 0, currentObject);
// 重新分配 order
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 },
}),
);
await Promise.all(updatePromises);
// 返回更新后的当前节点
return transaction[this.objectType].findUnique({
where: { id: currentObject.id },
});
});
}
// 重新分配 order 的方法
private redistributeOrder(
nodes: Array<{ id: string; order: number }>,
parentOrder: number,
): number[] {
const MIN_CHILD_ORDER = parentOrder + this.ORDER_INTERVAL; // 子节点 order 必须大于父节点
const newOrders: number[] = [];
nodes.forEach((_, index) => {
// 使用等差数列分配 order确保大于父节点
const nodeOrder = MIN_CHILD_ORDER + (index + 1) * this.ORDER_INTERVAL;
newOrders.push(nodeOrder);
});
return newOrders;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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', // 空白操作符
});
}
}

View File

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

View File

@ -1,4 +1,9 @@
import { UserProfile, db, DeptSimpleTreeNode, TreeDataNode } from "@nice/common";
import {
UserProfile,
db,
DeptSimpleTreeNode,
TreeDataNode,
} from '@nice/common';
/**
* DeptSimpleTreeNode结构
@ -15,16 +20,16 @@ import { UserProfile, db, DeptSimpleTreeNode, TreeDataNode } from "@nice/common"
* - hasStaff: 该部门是否包含员工
*/
export function mapToDeptSimpleTree(department: any): DeptSimpleTreeNode {
return {
id: department.id,
key: department.id,
value: department.id,
title: department.name,
order: department.order,
pId: department.parentId,
isLeaf: !Boolean(department.children?.length),
hasStaff: department?.deptStaffs?.length > 0
};
return {
id: department.id,
key: department.id,
value: department.id,
title: department.name,
order: department.order,
pId: department.parentId,
isLeaf: !Boolean(department.children?.length),
hasStaff: department?.deptStaffs?.length > 0,
};
}
/**
@ -36,15 +41,15 @@ export function mapToDeptSimpleTree(department: any): DeptSimpleTreeNode {
* - 使flatMap将查询结果扁平化ID
*/
export async function getStaffsByDeptIds(ids: string[]) {
const depts = await db.department.findMany({
where: { id: { in: ids } },
select: {
deptStaffs: {
select: { id: true }
}
},
});
return depts.flatMap((dept) => dept.deptStaffs);
const depts = await db.department.findMany({
where: { id: { in: ids } },
select: {
deptStaffs: {
select: { id: true },
},
},
});
return depts.flatMap((dept) => dept.deptStaffs);
}
/**
@ -57,12 +62,16 @@ export async function getStaffsByDeptIds(ids: string[]) {
* - ID
* - ID列表
*/
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));
if (staff) {
result.delete(staff.id);
}
return Array.from(result);
}
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));
if (staff) {
result.delete(staff.id);
}
return Array.from(result);
}

View File

@ -1,11 +1,11 @@
import { z } from "zod";
import { z } from 'zod';
export const EnrollSchema = z.object({
studentId: z.string(),
courseId: z.string(),
studentId: z.string(),
postId: z.string(),
});
export const UnenrollSchema = z.object({
studentId: z.string(),
courseId: z.string(),
});
studentId: z.string(),
postId: z.string(),
});

View File

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

View File

@ -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,58 +21,58 @@ 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,
},
});
return enrollment;
});
EventBus.emit('dataChanged', {
type: ObjectType.ENROLLMENT,
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', {
type: ObjectType.ENROLLMENT,
operation: CrudOperation.UPDATED,
data: result,
});
return result
return result;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,44 +1,125 @@
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
const limitedComments = await db.post.findMany({
where: {
parentId: data.id,
type: PostType.POST_COMMENT,
},
include: {
author: true,
},
take: 5,
});
const commentsCount = await db.post.count({
where: {
parentId: data.id,
type: PostType.POST_COMMENT,
},
});
const readed =
(await db.visit.count({
where: {
postId: data.id,
type: VisitType.READED,
visitorId: staff?.id,
},
})) > 0;
const readedCount = await db.visit.count({
where: {
postId: data.id,
type: VisitType.READED,
},
});
export async function setPostRelation(params: {
data: Post;
staff?: UserProfile;
}) {
const { data, staff } = params;
const limitedComments = await db.post.findMany({
where: {
parentId: data.id,
type: PostType.POST_COMMENT,
},
include: {
author: true,
},
take: 5,
});
const commentsCount = await db.post.count({
where: {
parentId: data.id,
type: PostType.POST_COMMENT,
},
});
const readed =
(await db.visit.count({
where: {
postId: data.id,
type: VisitType.READED,
visitorId: staff?.id,
},
})) > 0;
const readedCount = await db.visit.count({
where: {
postId: data.id,
type: VisitType.READED,
},
});
Object.assign(data, {
readed,
readedCount,
limitedComments,
commentsCount,
// trouble
})
Object.assign(data, {
readed,
readedCount,
limitedComments,
commentsCount,
// trouble
});
}
export async function updateParentLectureStats(parentId: string) {
const ParentStats = await db.post.aggregate({
where: {
ancestors: {
some: {
ancestorId: parentId,
descendant: {
type: PostType.LECTURE,
deletedAt: null,
},
},
},
},
_count: { _all: true },
_sum: {
duration: true,
},
});
await db.post.update({
where: { id: parentId },
data: {
//totalLectures: courseStats._count._all,
//totalDuration: courseStats._sum.duration || 0,
},
});
}
}
// 更新课程评价统计
export async function updateCourseReviewStats(courseId: string) {
const reviews = await db.visit.findMany({
where: {
postId: courseId,
type: PostType.COURSE_REVIEW,
deletedAt: null,
},
select: { views: true },
});
const numberOfReviews = reviews.length;
const averageRating =
numberOfReviews > 0
? reviews.reduce((sum, review) => sum + review.views, 0) / numberOfReviews
: 0;
return db.post.update({
where: { id: courseId },
data: {
// numberOfReviews,
//averageRating,
},
});
}
// 更新课程注册统计
export async function updateCourseEnrollmentStats(courseId: string) {
const completedEnrollments = await db.enrollment.count({
where: {
postId: courseId,
status: EnrollmentStatus.COMPLETED,
},
});
const totalEnrollments = await db.enrollment.count({
where: { postId: courseId },
});
const completionRate =
totalEnrollments > 0 ? (completedEnrollments / totalEnrollments) * 100 : 0;
return db.post.update({
where: { id: courseId },
data: {
// numberOfStudents: totalEnrollments,
// completionRate,
},
});
}

View File

@ -1,26 +1,25 @@
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)
}
/**
*
* @param data ID列表的数据
* @returns
* @throws ID
*/
async softDeleteByIds(ids: string[], data?: Prisma.RoleUpdateInput) {
await db.roleMap.deleteMany({
where: {
roleId: {
in: ids
}
}
});
return await super.softDeleteByIds(ids, data)
}
constructor() {
super(db, ObjectType.ROLE);
}
/**
*
* @param data ID列表的数据
* @returns
* @throws ID
*/
async softDeleteByIds(ids: string[], data?: Prisma.RoleUpdateInput) {
await db.roleMap.deleteMany({
where: {
roleId: {
in: ids,
},
},
});
return await super.softDeleteByIds(ids, data);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { db, TaxonomyMethodSchema, Prisma } from '@nice/common';
import { db, TaxonomyMethodSchema, Prisma } from '@nice/common';
import { redis } from '@server/utils/redis/redis.service';
import { deleteByPattern } from '@server/utils/redis/utils';
import { TRPCError } from '@trpc/server';
@ -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,
}
},
});
}
}

View File

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

View File

@ -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';
@ -184,7 +179,7 @@ export class TransformService {
return !cell || cell.toString().trim() === '';
});
}
async importStaffs(data: z.infer<typeof TransformMethodSchema.importStaffs>) {
const { base64, domainId } = data;
this.logger.log('开始');
@ -543,5 +538,4 @@ export class TransformService {
// },
// });
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
import { TrpcRouter } from './trpc.router';
export type AppRouter = TrpcRouter[`appRouter`];

View File

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

View File

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

View File

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

View File

@ -5,37 +5,44 @@ import { CourseCard } from "@web/src/components/models/course/card/CourseCard";
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({
page: currentPage,
pageSize: 8,
where: {
enrollments: {
some: {
studentId: user?.id
}
}
}
});
const handlePageChange = (page: number) => {
setCurrentPage(page);
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>
</div>
<CourseList
totalPages={paginationRes?.totalPages}
onPageChange={handlePageChange}
currentPage={currentPage}
courses={paginationRes?.items as any}
renderItem={(course) => <CourseCard course={course}></CourseCard>}>
</CourseList>
</div>
</div>
);
}
const [currentPage, setCurrentPage] = useState(1);
const { user } = useAuth();
const { data: paginationRes, refetch } =
api.post.findManyWithPagination.useQuery({
page: currentPage,
pageSize: 8,
where: {
enrollments: {
some: {
studentId: user?.id,
},
},
},
});
const handlePageChange = (page: number) => {
setCurrentPage(page);
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>
</div>
<CourseList
totalPages={paginationRes?.totalPages}
onPageChange={handlePageChange}
currentPage={currentPage}
courses={paginationRes?.items as any}
renderItem={(course) => (
<CourseCard course={course}></CourseCard>
)}></CourseList>
</div>
</div>
);
}

View File

@ -1,49 +1,70 @@
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();
const navigate = useNavigate();
const { isAuthenticated, logout, user } = useAuth();
const navigate = useNavigate();
return (
<Menu className="w-48">
{isAuthenticated ? (
<>
<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()}
</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>
</div>
</div>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="settings" icon={<SettingOutlined />} 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"
>
退
</Menu.Item>
</>
) : (
<Menu.Item
key="login"
onClick={() => navigate("/login")}
className="px-4 text-blue-500 hover:text-blue-600 hover:bg-blue-50"
>
/
</Menu.Item>
)}
</Menu>
);
};
return (
<Menu className="w-48">
{isAuthenticated ? (
<>
<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()}
</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>
</div>
</div>
</Menu.Item>
<Menu.Divider />
<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">
退
</Menu.Item>
</>
) : (
<Menu.Item
key="login"
onClick={() => navigate("/login")}
className="px-4 text-blue-500 hover:text-blue-600 hover:bg-blue-50">
/
</Menu.Item>
)}
</Menu>
);
};

View File

@ -1,171 +1,198 @@
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';
modules?: any;
className?: string;
style?: React.CSSProperties;
onFocus?: () => void;
onBlur?: () => void;
onKeyDown?: (event: KeyboardEvent) => void;
onKeyUp?: (event: KeyboardEvent) => void;
maxLength?: number;
minLength?: number;
minRows?: number;
maxRows?: number;
value?: string;
onChange?: (content: string) => void;
placeholder?: string;
readOnly?: boolean;
theme?: "snow" | "bubble";
modules?: any;
className?: string;
style?: React.CSSProperties;
onFocus?: () => void;
onBlur?: () => void;
onKeyDown?: (event: KeyboardEvent) => void;
onKeyUp?: (event: KeyboardEvent) => void;
maxLength?: number;
minLength?: number;
minRows?: number;
maxRows?: number;
}
const QuillEditor: React.FC<QuillEditorProps> = ({
value = '',
onChange,
placeholder = '请输入内容...',
readOnly = false,
theme = 'snow',
modules = defaultModules,
className = '',
style = {},
onFocus,
onBlur,
onKeyDown,
onKeyUp,
maxLength,
minLength = 0,
minRows = 1,
maxRows
value = "",
onChange,
placeholder = "请输入内容...",
readOnly = false,
theme = "snow",
modules = defaultModules,
className = "",
style = {},
onFocus,
onBlur,
onKeyDown,
onKeyUp,
maxLength,
minLength = 0,
minRows = 1,
maxRows,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const quillRef = useRef<Quill | null>(null);
const isMounted = useRef(false);
const [charCount, setCharCount] = useState(0); // 添加字符计数状态
const handleTextChange = () => {
if (!quillRef.current) return;
const editor = quillRef.current;
// 获取文本并处理换行符
const text = editor.getText().replace(/\n$/, '');
const textLength = text.length;
const editorRef = useRef<HTMLDivElement>(null);
const quillRef = useRef<Quill | null>(null);
const isMounted = useRef(false);
const [charCount, setCharCount] = useState(0); // 添加字符计数状态
const handleTextChange = () => {
if (!quillRef.current) return;
const editor = quillRef.current;
// 获取文本并处理换行符
const text = editor.getText().replace(/\n$/, "");
const textLength = text.length;
// 处理最大长度限制
if (maxLength && textLength > maxLength) {
// 暂时移除事件监听器
editor.off('text-change', handleTextChange);
// 处理最大长度限制
if (maxLength && textLength > maxLength) {
// 暂时移除事件监听器
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') {
const remainingLength = maxLength - length;
if (length < maxLength) {
const truncatedText = op.insert.slice(0, remainingLength);
length += truncatedText.length;
acc.push({ ...op, insert: truncatedText });
}
} else {
acc.push(op);
}
return acc;
}, []);
// 更新内容
editor.setContents({ ops: newDelta } as any);
// 恢复光标位置
if (selection) {
editor.setSelection(Math.min(selection.index, maxLength));
}
// 重新计算截断后的实际长度
const finalText = editor.getText().replace(/\n$/, '');
setCharCount(finalText.length);
// 获取当前选区
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") {
const remainingLength = maxLength - length;
if (length < maxLength) {
const truncatedText = op.insert.slice(
0,
remainingLength
);
length += truncatedText.length;
acc.push({ ...op, insert: truncatedText });
}
} else {
acc.push(op);
}
return acc;
}, []);
// 更新内容
editor.setContents({ ops: newDelta } as any);
// 恢复光标位置
if (selection) {
editor.setSelection(Math.min(selection.index, maxLength));
}
// 重新计算截断后的实际长度
const finalText = editor.getText().replace(/\n$/, "");
setCharCount(finalText.length);
// 重新绑定事件监听器
editor.on('text-change', handleTextChange);
} else {
// 如果没有超出最大长度,直接更新字符计数
setCharCount(textLength);
}
// 重新绑定事件监听器
editor.on("text-change", handleTextChange);
} else {
// 如果没有超出最大长度,直接更新字符计数
setCharCount(textLength);
}
onChange?.(quillRef.current.root.innerHTML);
};
onChange?.(quillRef.current.root.innerHTML);
};
useEffect(() => {
if (!editorRef.current) return;
if (!isMounted.current) {
// 初始化 Quill 编辑器
quillRef.current = new Quill(editorRef.current, {
theme,
modules,
placeholder,
readOnly,
});
// 设置初始内容
quillRef.current.root.innerHTML = value;
if (onFocus) {
quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => {
if (range) {
onFocus();
}
});
}
if (onBlur) {
quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => {
if (!range) {
onBlur();
}
});
}
quillRef.current.on(Quill.events.TEXT_CHANGE, handleTextChange);
if (onKeyDown) {
quillRef.current.root.addEventListener('keydown', onKeyDown);
}
if (onKeyUp) {
quillRef.current.root.addEventListener('keyup', onKeyUp);
}
isMounted.current = true;
}
}, [theme, modules, placeholder, readOnly, onFocus, onBlur, onKeyDown, onKeyUp, maxLength, minLength]); // 添加所有相关的依赖
useEffect(() => {
if (quillRef.current) {
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;
editor.style.minHeight = `${minHeight}px`;
if (maxRows) {
const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom;
editor.style.maxHeight = `${maxHeight}px`;
editor.style.overflowY = 'auto';
}
}
}
}, [minRows, maxRows, quillRef.current]);
useEffect(() => {
if (!editorRef.current) return;
if (!isMounted.current) {
// 初始化 Quill 编辑器
quillRef.current = new Quill(editorRef.current, {
theme,
modules,
placeholder,
readOnly,
});
// 设置初始内容
quillRef.current.root.innerHTML = value;
if (onFocus) {
quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => {
if (range) {
onFocus();
}
});
}
if (onBlur) {
quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => {
if (!range) {
onBlur();
}
});
}
quillRef.current.on(Quill.events.TEXT_CHANGE, handleTextChange);
if (onKeyDown) {
quillRef.current.root.addEventListener("keydown", onKeyDown);
}
if (onKeyUp) {
quillRef.current.root.addEventListener("keyup", onKeyUp);
}
isMounted.current = true;
}
}, [
theme,
modules,
placeholder,
readOnly,
onFocus,
onBlur,
onKeyDown,
onKeyUp,
maxLength,
minLength,
]); // 添加所有相关的依赖
useEffect(() => {
if (quillRef.current) {
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;
editor.style.minHeight = `${minHeight}px`;
if (maxRows) {
const maxHeight =
lineHeight * maxRows + paddingTop + paddingBottom;
editor.style.maxHeight = `${maxHeight}px`;
editor.style.overflowY = "auto";
}
}
}
}, [minRows, maxRows, quillRef.current]);
// 监听 value 属性变化
useEffect(() => {
if (quillRef.current && value !== quillRef.current.root.innerHTML) {
quillRef.current.root.innerHTML = value;
}
}, [value]);
// 监听 value 属性变化
useEffect(() => {
if (quillRef.current && value !== quillRef.current.root.innerHTML) {
quillRef.current.root.innerHTML = value;
}
}, [value]);
return (
<div className={`quill-editor-container ${className}`} style={style}>
<div ref={editorRef} />
{(maxLength || minLength > 0) && (
<QuillCharCounter
currentCount={charCount}
maxLength={maxLength}
minLength={minLength}
/>
)}
</div>
);
return (
<div className={`quill-editor-container ${className}`} style={style}>
<div ref={editorRef} />
{(maxLength || minLength > 0) && (
<QuillCharCounter
currentCount={charCount}
maxLength={maxLength}
minLength={minLength}
/>
)}
</div>
);
};
export default QuillEditor;
export default QuillEditor;

View File

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

View File

@ -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>
<div className="space-y-3">
<Reorder.Group
axis="y"
values={items}
onReorder={updateItems}
className="space-y-3">
{items.map((item, index) => (
<Reorder.Item
key={item.id}
value={item}
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="group">
<div className="relative flex items-center">
<div className="flex-1 relative">
<input
{...register(`${name}.${index}`)}
{...inputProps}
value={item.value}
onChange={(e) => {
const newItems = items.map((i) =>
i.id === item.id
? {
...i,
<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">
{fields.map((field, index) => (
<Reorder.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 }}>
<div className="relative flex items-center group">
<Form.Item
{...field}
validateTrigger={[
"onChange",
"onBlur",
]}
rules={[
{
required: true,
whitespace: true,
message:
"请输入内容或删除此项",
},
]}
noStyle>
<Input
{...inputProps}
placeholder={placeholder}
style={{ width: "100%" }}
// suffix={
// inputProps.maxLength && (
// <Typography.Text type="secondary">
// {inputProps.maxLength -
// (
// Form.useWatch(
// [
// name,
// field.name,
// ]
// ) || ""
// ).length}
// </Typography.Text>
// )
// }
onChange={(e) => {
// 更新 items 状态
const newItems = [...items];
if (!newItems[index]) {
newItems[index] = {
id: field.key.toString(),
value: e.target
.value,
}
: i
);
updateItems(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(
items.filter(
(i) => i.id !== item.id
)
)
}
className="absolute -right-10 p-2 text-gray-400 hover:text-red-500 transition-colors rounded-md hover:bg-red-50 focus:ring-red-200 opacity-0 group-hover:opacity-100">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
</Reorder.Item>
))}
</Reorder.Group>
};
} else {
newItems[index].value =
e.target.value;
}
setItems(newItems);
}}
/>
</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 !== index
)
);
}}
/>
</div>
</Reorder.Item>
))}
</Reorder.Group>
<button
type="button"
onClick={() =>
updateItems([
...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>
</div>
<Button
type="primary"
ghost
icon={<PlusOutlined />}
onClick={() => {
add();
setItems([
...items,
{ id: UUIDGenerator.generate(), value: "" },
]);
}}>
{addButtonText}
</Button>
<Form.ErrorList errors={errors} />
</div>
</Form.Item>
)}
</Form.List>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
export interface MenuItemType {
icon: JSX.Element;
label: string;
action: () => void;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,97 +1,136 @@
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: []
},
});
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,
};
methods.reset(formData as any);
}
}, [course, methods]);
const onSubmit: SubmitHandler<CourseFormData> = async (data: CourseFormData) => {
try {
if (editId) {
await update.mutateAsync({
where: { id: editId },
data: {
...data
}
})
toast.success('课程更新成功!');
} else {
const result = await create.mutateAsync({
data: {
status: CourseStatus.DRAFT,
...data
}
})
navigate(`/course/${result.id}/editor`, { replace: true })
toast.success('课程创建成功!');
}
methods.reset(data);
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,
content: course.content,
thumbnail: course?.meta?.thumbnail,
requirements: course?.meta?.requirements,
objectives: course?.meta?.objectives,
};
form.setFieldsValue(formData);
}
}, [course, form]);
} catch (error) {
console.error('Error submitting form:', error);
toast.error('操作失败,请重试!');
}
};
return (
<CourseEditorContext.Provider value={{ onSubmit, editId, course }}>
<FormProvider {...methods}>
{children}
</FormProvider>
</CourseEditorContext.Provider>
);
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: formattedValues,
});
message.success("课程更新成功!");
} else {
const result = await createCourse.mutateAsync({
courseDetail: {
data: {
title: formattedValues.title || "12345",
state: CourseStatus.DRAFT,
type: PostType.COURSE,
...formattedValues,
},
},
sections,
});
navigate(`/course/${result.id}/editor`, { replace: true });
message.success("课程创建成功!");
}
form.resetFields();
} catch (error) {
console.error("Error submitting form:", error);
message.error("操作失败,请重试!");
}
};
return (
<CourseEditorContext.Provider
value={{
onSubmit,
editId,
course,
taxonomies,
form,
}}>
<Form
form={form}
onFinish={onSubmit}
initialValues={{
requirements: [],
objectives: [],
}}>
{children}
</Form>
</CourseEditorContext.Provider>
);
}
export const useCourseEditor = () => {

View File

@ -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>();
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>
// 将 CourseLevelLabel 转换为 Ant Design Select 需要的选项格式
const levelOptions = Object.entries(CourseLevelLabel).map(
([key, value]) => ({
label: value,
value: key as CourseLevel,
})
);
const { form, taxonomies } = useCourseEditor();
return (
<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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,53 +1,77 @@
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';
const courseStatusVariant: Record<CourseStatus, string> = {
[CourseStatus.DRAFT]: 'default',
[CourseStatus.UNDER_REVIEW]: 'warning',
[CourseStatus.PUBLISHED]: 'success',
[CourseStatus.ARCHIVED]: 'danger'
};
export default function CourseEditorHeader() {
const navigate = useNavigate();
const { handleSubmit, formState: { isValid, isDirty, errors } } = useFormContext<CourseFormData>()
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 { 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
onClick={() => navigate(-1)}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeftIcon className="w-5 h-5 text-gray-600" />
</button>
<div className="flex items-center space-x-2">
<h2 className="font-medium text-gray-900">{course?.title || '新建课程'}</h2>
<Tag variant={courseStatusVariant[course?.status || CourseStatus.DRAFT]}>
{course?.status ? CourseStatusLabel[course.status] : 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}
</div>
</div>
<Button
disabled={course ? (!isValid || !isDirty) : !isValid}
size="sm"
onClick={handleSubmit(onSubmit)}
>
</Button>
</div>
</header>
);
}
const { Title } = Typography;
const courseStatusVariant: Record<CourseStatus, string> = {
[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 handleSave = () => {
try {
form.validateFields().then((values) => {
onSubmit(values);
});
} catch (err) {
console.log(err);
}
};
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
icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)}
type="text"
/>
<div className="flex items-center space-x-2">
<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?.duration && (
<span className="hidden md:flex items-center text-gray-500 text-sm">
<ClockCircleOutlined
style={{ marginRight: 4 }}
/>
{course.duration}
</span>
)}
</div>
</div>
<Button
type="primary"
size="small"
onClick={handleSave}
// disabled={form
// .getFieldsError()
// .some(({ errors }) => errors.length)}
>
</Button>
</div>
</header>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 })[] => [
{
label: "课程概述",
icon: <BookOpenIcon className="w-5 h-5" />,
path: `/course/${courseId ? `${courseId}/` : ''}editor`
},
{
label: "目标学员",
icon: <AcademicCapIcon className="w-5 h-5" />,
path: `/course/${courseId ? `${courseId}/` : ''}editor/goal`
},
{
label: "课程内容",
icon: <VideoCameraIcon className="w-5 h-5" />,
path: `/course/${courseId ? `${courseId}/` : ''}editor/content`
},
{
label: "课程设置",
icon: <Cog6ToothIcon className="w-5 h-5" />,
path: `/course/${courseId ? `${courseId}/` : ''}editor/setting`
},
];
export const getNavItems = (
courseId?: string
): (NavItem & { isInitialized?: boolean; isCompleted?: boolean })[] => [
{
label: "课程概述",
icon: <BookOpenIcon className="w-5 h-5" />,
path: `/course/${courseId ? `${courseId}/` : ""}editor`,
isInitialized: true,
},
{
label: "目标学员",
icon: <AcademicCapIcon className="w-5 h-5" />,
path: `/course/${courseId ? `${courseId}/` : ""}editor/goal`,
isInitialized: false,
},
{
label: "课程内容",
icon: <VideoCameraIcon className="w-5 h-5" />,
path: `/course/${courseId ? `${courseId}/` : ""}editor/content`,
isInitialized: false,
},
{
label: "课程设置",
icon: <Cog6ToothIcon className="w-5 h-5" />,
path: `/course/${courseId ? `${courseId}/` : ""}editor/setting`,
isInitialized: false,
},
];

View File

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

View File

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

View File

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

View File

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

View File

@ -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: [{
index: true,
element: <CourseBasicForm></CourseBasicForm>
},
{
path: 'goal',
element: <CourseGoalForm></CourseGoalForm>
},
{
path: 'content',
element: <CourseContentForm></CourseContentForm>
},
{
path: 'setting',
element: <CourseSettingForm></CourseSettingForm>
}
]
children: [
{
index: true,
element: <CourseBasicForm></CourseBasicForm>,
},
{
path: "goal",
element: <CourseGoalForm></CourseGoalForm>,
},
{
path: "content",
element: (
<CourseContentForm></CourseContentForm>
),
},
{
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,
],
},
{

View File

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

View File

@ -1,41 +1,42 @@
{
"name": "@nice/client",
"version": "1.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"sideEffects": false,
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"clean": "rimraf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"tinycolor2": "^1.6.0"
},
"peerDependencies": {
"@nice/common": "workspace:^",
"@tanstack/query-async-storage-persister": "^5.51.9",
"@tanstack/react-query": "^5.51.21",
"@tanstack/react-query-persist-client": "^5.51.9",
"@trpc/client": "11.0.0-rc.456",
"@trpc/react-query": "11.0.0-rc.456",
"@trpc/server": "11.0.0-rc.456",
"dayjs": "^1.11.12",
"lib0": "^0.2.98",
"mitt": "^3.0.1",
"react": "18.2.0",
"yjs": "^13.6.20",
"axios": "^1.7.2"
},
"devDependencies": {
"rimraf": "^6.0.1",
"tsup": "^8.3.5",
"typescript": "^5.5.4"
}
}
"name": "@nice/client",
"version": "1.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"sideEffects": false,
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"dev-static": "tsup --no-watch",
"clean": "rimraf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"tinycolor2": "^1.6.0"
},
"peerDependencies": {
"@nice/common": "workspace:^",
"@tanstack/query-async-storage-persister": "^5.51.9",
"@tanstack/react-query": "^5.51.21",
"@tanstack/react-query-persist-client": "^5.51.9",
"@trpc/client": "11.0.0-rc.456",
"@trpc/react-query": "11.0.0-rc.456",
"@trpc/server": "11.0.0-rc.456",
"dayjs": "^1.11.12",
"lib0": "^0.2.98",
"mitt": "^3.0.1",
"react": "18.2.0",
"yjs": "^13.6.20",
"axios": "^1.7.2"
},
"devDependencies": {
"rimraf": "^6.0.1",
"tsup": "^8.3.5",
"typescript": "^5.5.4"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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