From 199d025cd9cad739ebbf4adf8c6a852c545854aa Mon Sep 17 00:00:00 2001 From: ditiqi Date: Fri, 24 Jan 2025 00:19:02 +0800 Subject: [PATCH] add 001749 --- apps/server/src/models/post/post.service.ts | 30 +++ apps/server/src/models/post/utils.ts | 33 ++- apps/server/src/models/visit/utils.ts | 15 ++ apps/server/src/models/visit/visit.router.ts | 65 +++--- apps/server/src/models/visit/visit.service.ts | 53 ++++- .../queue/models/post/post.queue.service.ts | 26 +++ apps/server/src/queue/models/post/utils.ts | 50 ++++ .../queue/postprocess/postprocess.service.ts | 44 ++-- apps/server/src/queue/queue.module.ts | 10 +- apps/server/src/queue/stats/stats.service.ts | 128 +++++----- apps/server/src/queue/types.ts | 10 +- .../server/src/queue/worker/file.processor.ts | 24 +- apps/server/src/queue/worker/processor.ts | 4 + apps/server/src/trpc/trpc.service.ts | 6 +- apps/server/src/utils/event-bus.ts | 23 +- apps/web/package.json | 1 + apps/web/src/app/main/letter/detail/page.tsx | 9 +- apps/web/src/app/main/letter/write/header.tsx | 113 +++++---- .../common/editor/quill/constants.ts | 21 +- .../context/LetterEditorContext.tsx | 2 +- .../LetterEditor/form/LetterBasicForm.tsx | 19 +- .../layout/LetterEditorLayout.tsx | 2 +- .../models/post/detail/LoadingCard.tsx | 71 ++++++ .../models/post/detail/PostCommentCard.tsx | 91 ++++++++ .../models/post/detail/PostCommentEditor.tsx | 119 ++++++++++ .../models/post/detail/PostCommentList.tsx | 134 +++++++++++ .../models/post/detail/PostDetail.tsx | 4 + .../models/post/detail/PostHeader.tsx | 219 ++++++++++++++++++ .../post/detail/context/PostDetailContext.tsx | 15 +- .../post/detail/layout/PostDetailLayout.tsx | 34 ++- .../components/models/post/detail/utils.ts | 7 + apps/web/src/index.css | 212 ++++++++++++++++- packages/client/src/api/hooks/useVisitor.ts | 139 ++++++----- packages/client/src/singleton/DataHolder.ts | 57 +++-- packages/common/prisma/schema.prisma | 10 +- packages/common/src/enum.ts | 133 ++++++----- packages/common/src/select.ts | 44 ++++ packages/common/src/types.ts | 14 +- pnpm-lock.yaml | 34 ++- 39 files changed, 1603 insertions(+), 422 deletions(-) create mode 100644 apps/server/src/models/visit/utils.ts create mode 100644 apps/server/src/queue/models/post/post.queue.service.ts create mode 100644 apps/server/src/queue/models/post/utils.ts create mode 100644 apps/web/src/components/models/post/detail/LoadingCard.tsx create mode 100644 apps/web/src/components/models/post/detail/PostCommentCard.tsx create mode 100644 apps/web/src/components/models/post/detail/PostCommentEditor.tsx create mode 100644 apps/web/src/components/models/post/detail/PostCommentList.tsx create mode 100644 apps/web/src/components/models/post/detail/PostHeader.tsx create mode 100644 apps/web/src/components/models/post/detail/utils.ts diff --git a/apps/server/src/models/post/post.service.ts b/apps/server/src/models/post/post.service.ts index e6207bf..b3df446 100755 --- a/apps/server/src/models/post/post.service.ts +++ b/apps/server/src/models/post/post.service.ts @@ -27,8 +27,10 @@ export class PostService extends BaseService { params: { staff?: UserProfile; tx?: Prisma.PostDelegate }, ) { args.data.authorId = params?.staff?.id; + args.data.updatedAt = new Date(); // args.data.resources const result = await super.create(args); + await this.updateParentTimestamp(result?.parentId); EventBus.emit('dataChanged', { type: ObjectType.POST, operation: CrudOperation.CREATED, @@ -38,6 +40,7 @@ export class PostService extends BaseService { } async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) { args.data.authorId = staff?.id; + args.data.updatedAt = new Date(); const result = await super.update(args); EventBus.emit('dataChanged', { type: ObjectType.POST, @@ -46,6 +49,17 @@ export class PostService extends BaseService { }); return result; } + async findFirst(args?: Prisma.PostFindFirstArgs, staff?: UserProfile) { + const transDto = await this.wrapResult( + super.findFirst(args), + async (result) => { + await setPostRelation({ data: result, staff }); + await this.setPerms(result, staff); + return result; + }, + ); + return transDto; + } async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) { if (!args.where) args.where = {}; args.where.OR = await this.preFilter(args.where.OR, staff); @@ -125,4 +139,20 @@ export class PostService extends BaseService { if (orCondition?.length > 0) return orCondition; return undefined; } + /** + * 更新父帖子的时间戳 + * 当子帖子被创建时,自动更新父帖子的更新时间 + * @param parentId 父帖子的ID + */ + async updateParentTimestamp(parentId: string | undefined) { + if (!parentId) { + return; + } + await this.update({ + where: { + id: parentId, + }, + data: {}, // 空对象会自动更新 updatedAt 时间戳 + }); + } } diff --git a/apps/server/src/models/post/utils.ts b/apps/server/src/models/post/utils.ts index eb51237..a7defc3 100644 --- a/apps/server/src/models/post/utils.ts +++ b/apps/server/src/models/post/utils.ts @@ -5,16 +5,9 @@ export async function setPostRelation(params: { 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 thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000); + const clientIp = (data?.meta as any)?.ip; const commentsCount = await db.post.count({ where: { parentId: data.id, @@ -29,6 +22,23 @@ export async function setPostRelation(params: { visitorId: staff?.id, }, })) > 0; + const liked = await db.visit.count({ + where: { + postId: data.id, + type: VisitType?.LIKE, + ...(staff?.id + ? // 如果有 staff,查找对应的 visitorId + { visitorId: staff.id } + : // 如果没有 staff,查找相同 IP 且 visitorId 为 null 且 30 分钟内的记录 + { + visitorId: null, + meta: { path: ['ip'], equals: clientIp }, + updatedAt: { + gte: thirtyMinutesAgo, + }, + }), + }, + }); const readedCount = await db.visit.count({ where: { postId: data.id, @@ -39,7 +49,8 @@ export async function setPostRelation(params: { Object.assign(data, { readed, readedCount, - limitedComments, + liked, + // limitedComments, commentsCount, // trouble }); diff --git a/apps/server/src/models/visit/utils.ts b/apps/server/src/models/visit/utils.ts new file mode 100644 index 0000000..0c285a3 --- /dev/null +++ b/apps/server/src/models/visit/utils.ts @@ -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 || ''; +} diff --git a/apps/server/src/models/visit/visit.router.ts b/apps/server/src/models/visit/visit.router.ts index 2bb9064..3dbe053 100644 --- a/apps/server/src/models/visit/visit.router.ts +++ b/apps/server/src/models/visit/visit.router.ts @@ -4,34 +4,45 @@ import { Prisma } from '@nice/common'; import { VisitService } from './visit.service'; import { z, ZodType } from 'zod'; -const VisitCreateArgsSchema: ZodType = z.any() -const VisitCreateManyInputSchema: ZodType = z.any() -const VisitDeleteManyArgsSchema: ZodType = z.any() +import { getClientIp } from './utils'; +const VisitCreateArgsSchema: ZodType = z.any(); +const VisitCreateManyInputSchema: ZodType = + z.any(); +const VisitDeleteManyArgsSchema: ZodType = z.any(); @Injectable() export class VisitRouter { - constructor( - private readonly trpc: TrpcService, - private readonly visitService: VisitService, - ) { } - router = this.trpc.router({ - create: this.trpc.protectProcedure - .input(VisitCreateArgsSchema) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.visitService.create(input, staff); - }), - createMany: this.trpc.protectProcedure.input(z.array(VisitCreateManyInputSchema)) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; + constructor( + private readonly trpc: TrpcService, + private readonly visitService: VisitService, + ) {} + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(VisitCreateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff, req } = ctx; + // 从请求中获取 IP + const ip = getClientIp(req); + const currentMeta = + typeof input.data.meta === 'object' && input.data.meta !== null + ? input.data.meta + : {}; + input.data.meta = { + ...currentMeta, + ip: ip || '', + } as Prisma.InputJsonObject; // 明确指定类型 + return await this.visitService.create(input, staff); + }), + createMany: this.trpc.protectProcedure + .input(z.array(VisitCreateManyInputSchema)) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; - return await this.visitService.createMany({ data: input }, staff); - }), - deleteMany: this.trpc.procedure - .input(VisitDeleteManyArgsSchema) - .mutation(async ({ input }) => { - return await this.visitService.deleteMany(input); - }), - - - }); + return await this.visitService.createMany({ data: input }, staff); + }), + deleteMany: this.trpc.procedure + .input(VisitDeleteManyArgsSchema) + .mutation(async ({ input }) => { + return await this.visitService.deleteMany(input); + }), + }); } diff --git a/apps/server/src/models/visit/visit.service.ts b/apps/server/src/models/visit/visit.service.ts index 9e8faad..20d0a30 100644 --- a/apps/server/src/models/visit/visit.service.ts +++ b/apps/server/src/models/visit/visit.service.ts @@ -9,13 +9,28 @@ export class VisitService extends BaseService { } async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) { const { postId, messageId } = args.data; + const clientIp = (args.data.meta as any)?.ip; + console.log('visit create'); const visitorId = args.data.visitorId || staff?.id; let result; const existingVisit = await db.visit.findFirst({ where: { type: args.data.type, - visitorId, - OR: [{ postId }, { messageId }], + OR: [ + { + AND: [ + { OR: [{ postId }, { messageId }] }, + { visitorId: visitorId || null }, + ], + }, + { + AND: [ + { OR: [{ postId }, { messageId }] }, + { visitorId: null }, + { meta: { path: ['ip'], equals: clientIp } }, + ], + }, + ], }, }); if (!existingVisit) { @@ -28,14 +43,36 @@ export class VisitService extends BaseService { views: existingVisit.views + 1, }, }); + } else if (args.data.type === VisitType.LIKE) { + if (!visitorId && existingVisit) { + const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000); + if (existingVisit.updatedAt < thirtyMinutesAgo) { + // 如果上次更新时间超过30分钟,增加view计数 + result = await super.update({ + where: { id: existingVisit.id }, + data: { + ...args.data, + views: existingVisit.views + 1, + }, + }); + } + } } - // if (troubleId && args.data.type === VisitType.READED) { - // EventBus.emit('updateViewCount', { - // objectType: ObjectType.TROUBLE, - // id: troubleId, - // }); - // } + if (postId && args.data.type === VisitType.READED) { + EventBus.emit('updateVisitCount', { + objectType: ObjectType.POST, + id: postId, + visitType: VisitType.READED, + }); + } + if (postId && args.data.type === VisitType.LIKE) { + EventBus.emit('updateVisitCount', { + objectType: ObjectType.POST, + id: postId, + visitType: VisitType.LIKE, + }); + } return result; } async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) { diff --git a/apps/server/src/queue/models/post/post.queue.service.ts b/apps/server/src/queue/models/post/post.queue.service.ts new file mode 100644 index 0000000..c11c6f1 --- /dev/null +++ b/apps/server/src/queue/models/post/post.queue.service.ts @@ -0,0 +1,26 @@ +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import EventBus from '@server/utils/event-bus'; +import { ObjectType, VisitType } from '@nice/common'; +import { QueueJobType, updateVisitCountJobData } from '@server/queue/types'; + +@Injectable() +export class PostQueueService implements OnModuleInit { + private readonly logger = new Logger(PostQueueService.name); + constructor(@InjectQueue('general') private generalQueue: Queue) {} + onModuleInit() { + EventBus.on('updateVisitCount', ({ id, objectType, visitType }) => { + console.log('updateVisitCount'); + if (objectType === ObjectType.POST) { + this.addUpdateVisitCountJob({ id, type: visitType }); + } + }); + } + async addUpdateVisitCountJob(data: updateVisitCountJobData) { + this.logger.log(`update post view count ${data.id}`); + await this.generalQueue.add(QueueJobType.UPDATE_POST_VISIT_COUNT, data, { + debounce: { id: data.id }, + }); + } +} diff --git a/apps/server/src/queue/models/post/utils.ts b/apps/server/src/queue/models/post/utils.ts new file mode 100644 index 0000000..7b91888 --- /dev/null +++ b/apps/server/src/queue/models/post/utils.ts @@ -0,0 +1,50 @@ +import { db, VisitType } from '@nice/common'; +export async function updatePostViewCount(id: string, type: VisitType) { + const totalViews = await db.visit.aggregate({ + _sum: { + views: true, + }, + where: { + postId: id, + type: type, + }, + }); + if (type === VisitType.READED) { + await db.post.update({ + where: { + id, + }, + data: { + views: totalViews._sum.views || 0, // Use 0 if no visits exist + }, + }); + } else if (type === VisitType.LIKE) { + await db.post.update({ + where: { + id, + }, + data: { + likes: totalViews._sum.views || 0, // Use 0 if no visits exist + }, + }); + } +} +export async function updatePostLikeCount(id: string) { + const totalViews = await db.visit.aggregate({ + _sum: { + views: true, + }, + where: { + postId: id, + type: VisitType.LIKE, + }, + }); + await db.post.update({ + where: { + id, + }, + data: { + views: totalViews._sum.views || 0, // Use 0 if no visits exist + }, + }); +} diff --git a/apps/server/src/queue/postprocess/postprocess.service.ts b/apps/server/src/queue/postprocess/postprocess.service.ts index ddbc821..eef571b 100644 --- a/apps/server/src/queue/postprocess/postprocess.service.ts +++ b/apps/server/src/queue/postprocess/postprocess.service.ts @@ -1,28 +1,24 @@ -import { InjectQueue } from "@nestjs/bullmq"; -import { Injectable } from "@nestjs/common"; -import EventBus from "@server/utils/event-bus"; -import { Queue } from "bullmq"; -import { ObjectType } from "@nice/common"; -import { QueueJobType } from "../types"; +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable } from '@nestjs/common'; +import EventBus from '@server/utils/event-bus'; +import { Queue } from 'bullmq'; +import { ObjectType } from '@nice/common'; +import { QueueJobType } from '../types'; @Injectable() export class PostProcessService { - constructor( - @InjectQueue('general') private generalQueue: Queue - ) { - - } + constructor(@InjectQueue('general') private generalQueue: Queue) {} - private generateJobId(type: ObjectType, data: any): string { - // 根据类型和相关ID生成唯一的job标识 - switch (type) { - case ObjectType.ENROLLMENT: - return `stats_${type}_${data.courseId}`; - case ObjectType.LECTURE: - return `stats_${type}_${data.courseId}_${data.sectionId}`; - case ObjectType.POST: - return `stats_${type}_${data.courseId}`; - default: - return `stats_${type}_${Date.now()}`; - } + private generateJobId(type: ObjectType, data: any): string { + // 根据类型和相关ID生成唯一的job标识 + switch (type) { + case ObjectType.ENROLLMENT: + return `stats_${type}_${data.courseId}`; + case ObjectType.LECTURE: + return `stats_${type}_${data.courseId}_${data.sectionId}`; + case ObjectType.POST: + return `stats_${type}_${data.courseId}`; + default: + return `stats_${type}_${Date.now()}`; } -} \ No newline at end of file + } +} diff --git a/apps/server/src/queue/queue.module.ts b/apps/server/src/queue/queue.module.ts index 57a1a9d..aab8453 100755 --- a/apps/server/src/queue/queue.module.ts +++ b/apps/server/src/queue/queue.module.ts @@ -2,6 +2,7 @@ import { BullModule } from '@nestjs/bullmq'; import { Logger, Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { join } from 'path'; +import { PostQueueService } from './models/post/post.queue.service'; @Module({ imports: [ @@ -25,11 +26,10 @@ import { join } from 'path'; { name: 'file-queue', // 新增文件处理队列 processors: [join(__dirname, 'worker/file.processor.js')], // 文件处理器的路径 - } + }, ), ], - providers: [Logger], - exports: [] - + providers: [Logger, PostQueueService], + exports: [], }) -export class QueueModule { } +export class QueueModule {} diff --git a/apps/server/src/queue/stats/stats.service.ts b/apps/server/src/queue/stats/stats.service.ts index a498704..e8ce2dc 100644 --- a/apps/server/src/queue/stats/stats.service.ts +++ b/apps/server/src/queue/stats/stats.service.ts @@ -1,70 +1,68 @@ -import { InjectQueue } from "@nestjs/bullmq"; -import { Injectable } from "@nestjs/common"; -import EventBus from "@server/utils/event-bus"; -import { Queue } from "bullmq"; -import { ObjectType } from "@nice/common"; -import { QueueJobType } from "../types"; +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable } from '@nestjs/common'; +import EventBus from '@server/utils/event-bus'; +import { Queue } from 'bullmq'; +import { ObjectType } from '@nice/common'; +import { QueueJobType } from '../types'; @Injectable() export class StatsService { - constructor( - @InjectQueue('general') private generalQueue: Queue - ) { - EventBus.on("dataChanged", async ({ type, data }) => { - const jobOptions = { - removeOnComplete: true, - jobId: this.generateJobId(type as ObjectType, data) // 使用唯一ID防止重复任务 - }; - switch (type) { - case ObjectType.ENROLLMENT: - await this.generalQueue.add( - QueueJobType.UPDATE_STATS, - { - courseId: data.courseId, - type: ObjectType.ENROLLMENT - }, - jobOptions - ); - break; + constructor(@InjectQueue('general') private generalQueue: Queue) { + EventBus.on('dataChanged', async ({ type, data }) => { + const jobOptions = { + removeOnComplete: true, + jobId: this.generateJobId(type as ObjectType, data), // 使用唯一ID防止重复任务 + }; + switch (type) { + case ObjectType.ENROLLMENT: + await this.generalQueue.add( + QueueJobType.UPDATE_STATS, + { + courseId: data.courseId, + type: ObjectType.ENROLLMENT, + }, + jobOptions, + ); + break; - case ObjectType.LECTURE: - await this.generalQueue.add( - QueueJobType.UPDATE_STATS, - { - sectionId: data.sectionId, - courseId: data.courseId, - type: ObjectType.LECTURE - }, - jobOptions - ); - break; + case ObjectType.LECTURE: + await this.generalQueue.add( + QueueJobType.UPDATE_STATS, + { + sectionId: data.sectionId, + courseId: data.courseId, + type: ObjectType.LECTURE, + }, + jobOptions, + ); + break; - case ObjectType.POST: - if (data.courseId) { - await this.generalQueue.add( - QueueJobType.UPDATE_STATS, - { - courseId: data.courseId, - type: ObjectType.POST - }, - jobOptions - ); - } - break; - } - }); + case ObjectType.POST: + if (data.courseId) { + await this.generalQueue.add( + QueueJobType.UPDATE_STATS, + { + courseId: data.courseId, + type: ObjectType.POST, + }, + jobOptions, + ); + } + break; + } + }); + } + + private generateJobId(type: ObjectType, data: any): string { + // 根据类型和相关ID生成唯一的job标识 + switch (type) { + case ObjectType.ENROLLMENT: + return `stats_${type}_${data.courseId}`; + case ObjectType.LECTURE: + return `stats_${type}_${data.courseId}_${data.sectionId}`; + case ObjectType.POST: + return `stats_${type}_${data.courseId}`; + default: + return `stats_${type}_${Date.now()}`; } - - private generateJobId(type: ObjectType, data: any): string { - // 根据类型和相关ID生成唯一的job标识 - switch (type) { - case ObjectType.ENROLLMENT: - return `stats_${type}_${data.courseId}`; - case ObjectType.LECTURE: - return `stats_${type}_${data.courseId}_${data.sectionId}`; - case ObjectType.POST: - return `stats_${type}_${data.courseId}`; - default: - return `stats_${type}_${Date.now()}`; - } - } -} \ No newline at end of file + } +} diff --git a/apps/server/src/queue/types.ts b/apps/server/src/queue/types.ts index db4f92c..8cb367c 100755 --- a/apps/server/src/queue/types.ts +++ b/apps/server/src/queue/types.ts @@ -1,4 +1,10 @@ +import { VisitType } from 'packages/common/dist'; export enum QueueJobType { - UPDATE_STATS = "update_stats", - FILE_PROCESS = "file_process" + UPDATE_STATS = 'update_stats', + FILE_PROCESS = 'file_process', + UPDATE_POST_VISIT_COUNT = 'updatePostVisitCount', } +export type updateVisitCountJobData = { + id: string; + type: VisitType; +}; diff --git a/apps/server/src/queue/worker/file.processor.ts b/apps/server/src/queue/worker/file.processor.ts index de3d836..a37c266 100644 --- a/apps/server/src/queue/worker/file.processor.ts +++ b/apps/server/src/queue/worker/file.processor.ts @@ -6,17 +6,17 @@ import { ImageProcessor } from '@server/models/resource/processor/ImageProcessor import { VideoProcessor } from '@server/models/resource/processor/VideoProcessor'; const logger = new Logger('FileProcessorWorker'); const pipeline = new ResourceProcessingPipeline() - .addProcessor(new ImageProcessor()) - .addProcessor(new VideoProcessor()) + .addProcessor(new ImageProcessor()) + .addProcessor(new VideoProcessor()); export default async function processJob(job: Job) { - if (job.name === QueueJobType.FILE_PROCESS) { - console.log(job) - const { resource } = job.data; - if (!resource) { - throw new Error('No resource provided in job data'); - } - const result = await pipeline.execute(resource); - - return result; + if (job.name === QueueJobType.FILE_PROCESS) { + console.log(job); + const { resource } = job.data; + if (!resource) { + throw new Error('No resource provided in job data'); } -} \ No newline at end of file + const result = await pipeline.execute(resource); + + return result; + } +} diff --git a/apps/server/src/queue/worker/processor.ts b/apps/server/src/queue/worker/processor.ts index 93717af..a01d859 100755 --- a/apps/server/src/queue/worker/processor.ts +++ b/apps/server/src/queue/worker/processor.ts @@ -4,6 +4,7 @@ import { Logger } from '@nestjs/common'; import { ObjectType } from '@nice/common'; import { QueueJobType } from '../types'; +import { updatePostViewCount } from '../models/post/utils'; const logger = new Logger('QueueWorker'); export default async function processJob(job: Job) { try { @@ -37,6 +38,9 @@ export default async function processJob(job: Job) { `Updated course stats for courseId: ${courseId}, type: ${type}`, ); } + if (job.name === QueueJobType.UPDATE_POST_VISIT_COUNT) { + await updatePostViewCount(job.data.id, job.data.type); + } } catch (error: any) { logger.error( `Error processing stats update job: ${error.message}`, diff --git a/apps/server/src/trpc/trpc.service.ts b/apps/server/src/trpc/trpc.service.ts index 81ff772..d39cff2 100755 --- a/apps/server/src/trpc/trpc.service.ts +++ b/apps/server/src/trpc/trpc.service.ts @@ -47,9 +47,9 @@ export class TrpcService { // Define a protected procedure that ensures the user is authenticated protectProcedure = this.procedure.use(async ({ ctx, next }) => { - if (!ctx?.staff) { - throw new TRPCError({ code: 'UNAUTHORIZED', message: '未授权请求' }); - } + // if (!ctx?.staff) { + // throw new TRPCError({ code: 'UNAUTHORIZED', message: '未授权请求' }); + // } return next({ ctx: { // User value is confirmed to be non-null at this point diff --git a/apps/server/src/utils/event-bus.ts b/apps/server/src/utils/event-bus.ts index 8cc9c2e..e5a98f2 100644 --- a/apps/server/src/utils/event-bus.ts +++ b/apps/server/src/utils/event-bus.ts @@ -1,16 +1,25 @@ import mitt from 'mitt'; -import { ObjectType, UserProfile, MessageDto } from '@nice/common'; +import { ObjectType, UserProfile, MessageDto, VisitType } from '@nice/common'; export enum CrudOperation { CREATED, UPDATED, - DELETED + DELETED, } type Events = { - genDataEvent: { type: "start" | "end" }, - markDirty: { objectType: string, id: string, staff?: UserProfile, subscribers?: string[] } - updateViewCount: { id: string, objectType: ObjectType }, - onMessageCreated: { data: Partial }, - dataChanged: { type: string, operation: CrudOperation, data: any } + genDataEvent: { type: 'start' | 'end' }; + markDirty: { + objectType: string; + id: string; + staff?: UserProfile; + subscribers?: string[]; + }; + updateVisitCount: { + id: string; + objectType: ObjectType; + visitType: VisitType; + }; + onMessageCreated: { data: Partial }; + dataChanged: { type: string; operation: CrudOperation; data: any }; }; const EventBus = mitt(); export default EventBus; diff --git a/apps/web/package.json b/apps/web/package.json index 93e9ac4..db9cf24 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -64,6 +64,7 @@ "react-dropzone": "^14.3.5", "react-hook-form": "^7.54.2", "react-hot-toast": "^2.4.1", + "react-intersection-observer": "^9.15.1", "react-resizable": "^3.0.5", "react-router-dom": "^6.24.1", "superjson": "^2.2.1", diff --git a/apps/web/src/app/main/letter/detail/page.tsx b/apps/web/src/app/main/letter/detail/page.tsx index c43c603..43209d0 100644 --- a/apps/web/src/app/main/letter/detail/page.tsx +++ b/apps/web/src/app/main/letter/detail/page.tsx @@ -1,11 +1,8 @@ +import PostDetail from "@web/src/components/models/post/detail/PostDetail"; import { useParams } from "react-router-dom"; export default function LetterDetailPage() { const { id } = useParams(); - - return ( - <> -
{id}
- - ); + + return ; } diff --git a/apps/web/src/app/main/letter/write/header.tsx b/apps/web/src/app/main/letter/write/header.tsx index 0b1b6bd..c26d19c 100644 --- a/apps/web/src/app/main/letter/write/header.tsx +++ b/apps/web/src/app/main/letter/write/header.tsx @@ -1,46 +1,73 @@ export default function Header() { - return
-
- {/* 主标题 */} -
-

- 信件投递入口 -

-

- 保护您隐私的信件传输平台 -

-
+ return ( +
+
+ {/* 主标题 */} +
+

+ 信件投递入口 +

+

+ 保护您隐私的信件传输平台 +

+
- {/* 隐私保护说明 */} -
-
- - - - 个人信息严格保密 -
-
- - - - 支持匿名反映问题 -
-
- - - - 网络信息加密存储 -
-
+ {/* 隐私保护说明 */} +
+
+ + + + 个人信息严格保密 +
+
+ + + + 支持匿名反映问题 +
+
+ + + + 网络数据加密存储 +
+
- {/* 隐私承诺 */} -
-

我们承诺:您的个人信息将被严格保密,不向任何第三方透露。您可以选择匿名反映问题,平台会自动过滤可能暴露身份的信息。

-
-
-
- -} \ No newline at end of file + {/* 隐私承诺 */} +
+

+ 我们承诺:您的个人信息将被严格保密,不向任何第三方透露。您可以选择匿名反映问题,平台会自动过滤可能暴露身份的信息。 +

+
+
+
+ ); +} diff --git a/apps/web/src/components/common/editor/quill/constants.ts b/apps/web/src/components/common/editor/quill/constants.ts index d47f27b..af68aca 100644 --- a/apps/web/src/components/common/editor/quill/constants.ts +++ b/apps/web/src/components/common/editor/quill/constants.ts @@ -1,11 +1,12 @@ export const defaultModules = { - toolbar: [ - [{ 'header': [1, 2, 3, 4, 5, 6, false] }], - ['bold', 'italic', 'underline', 'strike'], - [{ 'list': 'ordered' }, { 'list': 'bullet' }], - [{ 'color': [] }, { 'background': [] }], - [{ 'align': [] }], - ['link', 'image'], - ['clean'] - ] -}; \ No newline at end of file + toolbar: [ + [{ header: [1, 2, 3, 4, 5, 6, false] }], + ["bold", "italic", "underline", "strike"], + [{ list: "ordered" }, { list: "bullet" }], + [{ color: [] }, { background: [] }], + [{ align: [] }], + ["link"], + // ['link', 'image'], + ["clean"], + ], +}; diff --git a/apps/web/src/components/models/post/LetterEditor/context/LetterEditorContext.tsx b/apps/web/src/components/models/post/LetterEditor/context/LetterEditorContext.tsx index 32f2969..5c6dcd4 100644 --- a/apps/web/src/components/models/post/LetterEditor/context/LetterEditorContext.tsx +++ b/apps/web/src/components/models/post/LetterEditor/context/LetterEditorContext.tsx @@ -83,7 +83,7 @@ export function LetterFormProvider({ : undefined, }, }); - // navigate(`/course/${result.id}/editor`, { replace: true }); + navigate(`/course/${result.id}/detail`, { replace: true }); toast.success("发送成功!"); methods.reset(data); diff --git a/apps/web/src/components/models/post/LetterEditor/form/LetterBasicForm.tsx b/apps/web/src/components/models/post/LetterEditor/form/LetterBasicForm.tsx index 64a6b73..c4465ad 100644 --- a/apps/web/src/components/models/post/LetterEditor/form/LetterBasicForm.tsx +++ b/apps/web/src/components/models/post/LetterEditor/form/LetterBasicForm.tsx @@ -60,8 +60,8 @@ export function LetterBasicForm() { initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.5 }}> - {/* 收件人 */} - { + {/* 收件人和板块信息行 */} +
收件人:{receiver?.showname}
- } - {/* 选择板块 */} - { +
板块:{term?.name}
- } +
{/* 主题输入框 */} - +{/* - + */} - 发送信件 + 提交 diff --git a/apps/web/src/components/models/post/LetterEditor/layout/LetterEditorLayout.tsx b/apps/web/src/components/models/post/LetterEditor/layout/LetterEditorLayout.tsx index 86309fd..f3a5c38 100644 --- a/apps/web/src/components/models/post/LetterEditor/layout/LetterEditorLayout.tsx +++ b/apps/web/src/components/models/post/LetterEditor/layout/LetterEditorLayout.tsx @@ -77,7 +77,7 @@ export default function LetterEditorLayout() { d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" /> - 网络信息加密存储 + 网络数据加密存储 diff --git a/apps/web/src/components/models/post/detail/LoadingCard.tsx b/apps/web/src/components/models/post/detail/LoadingCard.tsx new file mode 100644 index 0000000..3c07e7c --- /dev/null +++ b/apps/web/src/components/models/post/detail/LoadingCard.tsx @@ -0,0 +1,71 @@ +import { motion } from "framer-motion"; +import { SkeletonItem } from "../../../presentation/Skeleton"; + +interface LoadingCardProps { + count?: number; + className?: string; +} + +export function LoadingCard({ count = 3, className = "" }: LoadingCardProps) { + return ( +
+ {[...Array(count)].map((_, i) => ( + + {/* 闪光扫描效果 */} + + + {/* 内容骨架 */} +
+
+ +
+ + +
+
+ + +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/components/models/post/detail/PostCommentCard.tsx b/apps/web/src/components/models/post/detail/PostCommentCard.tsx new file mode 100644 index 0000000..c3d8c27 --- /dev/null +++ b/apps/web/src/components/models/post/detail/PostCommentCard.tsx @@ -0,0 +1,91 @@ +import { PostDto, VisitType } from "@nice/common"; +import { motion } from "framer-motion"; +import dayjs from "dayjs"; +import { ChatBubbleLeftIcon, HeartIcon } from "@heroicons/react/24/outline"; +import { HeartIcon as HeartIconSolid } from "@heroicons/react/24/solid"; +import { Avatar } from "@web/src/components/presentation/user/Avatar"; +import { useVisitor } from "@nice/client"; +import { useContext } from "react"; +import { PostDetailContext } from "./context/PostDetailContext"; + +export default function PostCommentCard({ + post, + index, +}: { + post: PostDto; + index: number; +}) { + const { user } = useContext(PostDetailContext); + const { like } = useVisitor(); + + async function likeThisPost() { + if (!post?.liked) { + try { + await like.mutateAsync({ + data: { + visitorId: user?.id || null, + postId: post.id, + type: VisitType.LIKE, + }, + }); + } catch (error) { + console.error('Failed to like post:', error); + } + } + } + return ( + +
+
+ +
+
+
+ + {post.author?.showname || "匿名用户"} + + + {dayjs(post?.createdAt).format("YYYY-MM-DD HH:mm")} + +
+
+ + {/* 添加有帮助按钮 */} +
+ + {post?.liked ? ( + + ) : ( + + )} + {post?.likes || 0} 有帮助 + +
+
+
+ + ); +} diff --git a/apps/web/src/components/models/post/detail/PostCommentEditor.tsx b/apps/web/src/components/models/post/detail/PostCommentEditor.tsx new file mode 100644 index 0000000..7f957e2 --- /dev/null +++ b/apps/web/src/components/models/post/detail/PostCommentEditor.tsx @@ -0,0 +1,119 @@ +import React, { useContext, useState } from "react"; +import { motion } from "framer-motion"; +import { PaperAirplaneIcon } from "@heroicons/react/24/solid"; +import { CommandLineIcon } from "@heroicons/react/24/outline"; +import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor"; +import { PostDetailContext } from "./context/PostDetailContext"; +import { usePost } from "@nice/client"; +import { PostType } from "@nice/common"; +import toast from "react-hot-toast"; +import { isContentEmpty } from "./utils"; + +export default function PostCommentEditor() { + const { post } = useContext(PostDetailContext); + const [content, setContent] = useState(""); + const [isPreview, setIsPreview] = useState(false); + const { create } = usePost(); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (isContentEmpty(content)) { + toast.error("内容不得为空"); + return; + } + + try { + await create.mutateAsync({ + data: { + type: PostType.POST_COMMENT, + parentId: post?.id, + content: content, + }, + }); + toast.success("发布成功!"); + setContent(""); + } catch (error) { + toast.error("发布失败,请稍后重试"); + console.error("Error posting comment:", error); + } + // TODO: 实现提交逻辑 + console.log("Submitting:", content); + setContent(""); + }; + + return ( + +
+
+ {!isPreview ? ( + + ) : ( + + )} +
+ +
+ {/* setIsPreview(!isPreview)} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + className={`flex items-center space-x-1 px-3 py-1.5 rounded-md + transition-colors ${ + isPreview + ? "bg-blue-600 text-white" + : "bg-slate-100 text-slate-600 hover:bg-slate-200" + }`}> + + {isPreview ? "编辑" : "预览"} + */} + + + + 提交 + +
+
+
+ ); +} diff --git a/apps/web/src/components/models/post/detail/PostCommentList.tsx b/apps/web/src/components/models/post/detail/PostCommentList.tsx new file mode 100644 index 0000000..0ef0102 --- /dev/null +++ b/apps/web/src/components/models/post/detail/PostCommentList.tsx @@ -0,0 +1,134 @@ +import React, { + useContext, + useMemo, + useEffect, + useRef, + useCallback, +} from "react"; +import { PostDetailContext } from "./context/PostDetailContext"; +import { api, useVisitor } from "@nice/client"; +import { postDetailSelect, PostDto, PostType, Prisma } from "@nice/common"; +import { motion, AnimatePresence } from "framer-motion"; +import PostCommentCard from "./PostCommentCard"; +import { useInView } from "react-intersection-observer"; +import { LoadingCard } from "@web/src/components/models/post/detail/LoadingCard"; + +export default function PostCommentList() { + const { post } = useContext(PostDetailContext); + const { ref: loadMoreRef, inView } = useInView(); + const { postParams } = useVisitor(); + const params: Prisma.PostFindManyArgs = useMemo(() => { + return { + where: { + parentId: post?.id, + type: PostType.POST_COMMENT, + }, + select: postDetailSelect, + orderBy: [ + { + createdAt: "desc", + }, + ], + take: 3, + }; + }, [post]); + const { + data: queryData, + fetchNextPage, + refetch, + isPending, + isFetchingNextPage, + hasNextPage, + isLoading, + isRefetching, + } = api.post.findManyWithCursor.useInfiniteQuery(params, { + enabled: !!post?.id, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }); + useEffect(() => { + if (post?.id) { + postParams.addItem(params); + return () => { + postParams.removeItem(params); + }; + } + }, [post, params]); + const items = useMemo(() => { + return queryData?.pages?.flatMap((page: any) => page.items) || []; + }, [queryData]); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (isLoading) { + return ; + } + + if (!items.length) { + return ( + + 暂无回复,来发表第一条回复吧 + + ); + } + + return ( +
+ + {items.map((comment, index) => ( + + + + ))} + + + {/* 加载更多触发器 */} +
+ {isFetchingNextPage ? ( +
+
+
+ ) : ( + items.length > 0 && + !hasNextPage && ( + +
+ + 已加载全部评论 + + + + ) + )} +
+
+ ); +} diff --git a/apps/web/src/components/models/post/detail/PostDetail.tsx b/apps/web/src/components/models/post/detail/PostDetail.tsx index 74eea24..5080d02 100644 --- a/apps/web/src/components/models/post/detail/PostDetail.tsx +++ b/apps/web/src/components/models/post/detail/PostDetail.tsx @@ -1,7 +1,11 @@ +import { useEffect } from "react"; import { PostDetailProvider } from "./context/PostDetailContext"; import PostDetailLayout from "./layout/PostDetailLayout"; export default function PostDetail({ id }: { id?: string }) { + + + return ( <> diff --git a/apps/web/src/components/models/post/detail/PostHeader.tsx b/apps/web/src/components/models/post/detail/PostHeader.tsx new file mode 100644 index 0000000..c314ca2 --- /dev/null +++ b/apps/web/src/components/models/post/detail/PostHeader.tsx @@ -0,0 +1,219 @@ +import { useContext } from "react"; +import { PostDetailContext } from "./context/PostDetailContext"; +import { motion } from "framer-motion"; +import { + CalendarIcon, + UserCircleIcon, + LockClosedIcon, + LockOpenIcon, + StarIcon, + ClockIcon, + EyeIcon, + ChatBubbleLeftIcon, +} from "@heroicons/react/24/outline"; +import { format } from "date-fns"; +import { useVisitor } from "@nice/client"; +import { VisitType } from "@nice/common"; + +export default function PostHeader() { + const { post, user } = useContext(PostDetailContext); + const { like } = useVisitor(); + + function likeThisPost() { + if (!post?.liked) { + like.mutateAsync({ + data: { + visitorId: user?.id || null, + postId: post.id, + type: VisitType.LIKE, + }, + }); + } + } + + return ( + + {/* Corner Decorations */} +
+
+ + {/* Title Section */} + +
+

+ {post?.title} +

+ + +
+ {/* First Row - Basic Info */} +
+ {/* Author Info Badge */} + + + + {post?.author?.showname || "匿名用户"} + + + + {/* Date Info Badge */} + {post?.createdAt && ( + + + + {format( + new Date(post?.createdAt), + "yyyy.MM.dd" + )} + + + )} + + {/* Last Updated Badge */} + {post?.updatedAt && post.updatedAt !== post.createdAt && ( + + + + 更新于:{" "} + {format( + new Date(post?.updatedAt), + "yyyy.MM.dd" + )} + + + )} + + {/* Visibility Status Badge */} + + {post?.isPublic ? ( + + ) : ( + + )} + + {post?.isPublic ? "公开" : "私信"} + + +
+ + {/* Second Row - Term and Tags */} +
+ {/* Term Badge */} + {post?.term?.name && ( + + + + {post.term.name} + + + )} + + {/* Tags Badges */} + {post?.meta?.tags && post.meta.tags.length > 0 && ( + + {post.meta.tags.map((tag, index) => ( + + + #{tag} + + + ))} + + )} +
+
+ + {/* Content Section */} + +
+ + + {/* Stats Section */} + + + + + {post?.likes || 0} 有帮助 + + + + + + {post?.views || 0} 浏览 + + + + + + {post?.commentsCount || 0} 评论 + + + + + ); +} diff --git a/apps/web/src/components/models/post/detail/context/PostDetailContext.tsx b/apps/web/src/components/models/post/detail/context/PostDetailContext.tsx index 0632ef4..8de8f9c 100644 --- a/apps/web/src/components/models/post/detail/context/PostDetailContext.tsx +++ b/apps/web/src/components/models/post/detail/context/PostDetailContext.tsx @@ -1,12 +1,13 @@ import { api, usePost } from "@nice/client"; -import { Post } from "@nice/common"; +import { Post, postDetailSelect, PostDto, UserProfile } from "@nice/common"; +import { useAuth } from "@web/src/providers/auth-provider"; import React, { createContext, ReactNode, useState } from "react"; -import { string } from "zod"; interface PostDetailContextType { editId?: string; // 添加 editId - post?: Post; + post?: PostDto; isLoading?: boolean; + user?: UserProfile; } interface PostFormProviderProps { children: ReactNode; @@ -19,15 +20,17 @@ export function PostDetailProvider({ children, editId, }: PostFormProviderProps) { - const { data: post, isLoading }: { data: Post; isLoading: boolean } = ( + const { user } = useAuth(); + const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = ( api.post.findFirst as any ).useQuery( { where: { id: editId }, + select: postDetailSelect, }, { enabled: Boolean(editId) } ); - // const {}:{} =( + // const {}:{} =( // api.post.fin as any // ) @@ -36,7 +39,7 @@ export function PostDetailProvider({ value={{ editId, post, - + user, isLoading, }}> {children} diff --git a/apps/web/src/components/models/post/detail/layout/PostDetailLayout.tsx b/apps/web/src/components/models/post/detail/layout/PostDetailLayout.tsx index d762aa9..21fecd8 100644 --- a/apps/web/src/components/models/post/detail/layout/PostDetailLayout.tsx +++ b/apps/web/src/components/models/post/detail/layout/PostDetailLayout.tsx @@ -1,12 +1,34 @@ import { motion } from "framer-motion"; -import { useContext } from "react"; +import { useContext, useEffect } from "react"; import { PostDetailContext } from "../context/PostDetailContext"; +import PostHeader from "../PostHeader"; +import PostCommentEditor from "../PostCommentEditor"; +import PostCommentList from "../PostCommentList"; +import { useVisitor } from "@nice/client"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { VisitType } from "@nice/common"; export default function PostDetailLayout() { - const { post } = useContext(PostDetailContext); + const { post, user } = useContext(PostDetailContext); + const { read } = useVisitor(); - return
- - -
; + useEffect(() => { + if (post) { + console.log("read"); + read.mutateAsync({ + data: { + visitorId: user?.id || null, + postId: post.id, + type: VisitType.READED, + }, + }); + } + }, [post]); + return ( +
+ + + +
+ ); } diff --git a/apps/web/src/components/models/post/detail/utils.ts b/apps/web/src/components/models/post/detail/utils.ts new file mode 100644 index 0000000..1cec3b0 --- /dev/null +++ b/apps/web/src/components/models/post/detail/utils.ts @@ -0,0 +1,7 @@ +export const isContentEmpty = (html: string) => { + // 创建一个临时 div 来解析 HTML 内容 + const temp = document.createElement("div"); + temp.innerHTML = html; + // 获取纯文本内容并检查是否为空 + return !temp.textContent?.trim(); +}; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d623ec5..53cc579 100755 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -6,13 +6,13 @@ border-top-left-radius: 8px; border-top-right-radius: 8px; border-bottom: none; - border: none + border: none; } .quill-editor-container .ql-container { border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; - border: none + border: none; } .ag-custom-dragging-class { @@ -45,11 +45,11 @@ background-color: transparent !important; } -.ant-table-thead>tr>th { +.ant-table-thead > tr > th { background-color: transparent !important; } -.ant-table-tbody>tr>td { +.ant-table-tbody > tr > td { background-color: transparent !important; border-bottom-color: transparent !important; } @@ -86,7 +86,9 @@ } /* 覆盖 Ant Design 的默认样式 raido button左侧的按钮*/ -.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled)::before { +.ant-radio-button-wrapper-checked:not( + .ant-radio-button-wrapper-disabled + )::before { background-color: unset !important; } @@ -99,7 +101,7 @@ display: none !important; } -.no-wrap-header .ant-table-thead>tr>th { +.no-wrap-header .ant-table-thead > tr > th { white-space: nowrap; } @@ -115,12 +117,206 @@ /* 设置单元格边框 */ } -.custom-table .ant-table-tbody>tr>td { +.custom-table .ant-table-tbody > tr > td { border-bottom: 1px solid #ddd; /* 设置表格行底部边框 */ } -.custom-table .ant-table-tbody>tr:last-child>td { +.custom-table .ant-table-tbody > tr:last-child > td { border-bottom: none; /* 去除最后一行的底部边框 */ } +.quill-editor-container .ql-toolbar.ql-snow, +.quill-editor-container .ql-container.ql-snow { + border-color: transparent; +} + +.quill-editor-container .ql-toolbar.ql-snow { + background: rgb(248, 250, 252); + border-top-left-radius: 0.5rem; + border-top-right-radius: 0.5rem; +} + +.quill-editor-container .ql-container.ql-snow { + background: transparent; + border-bottom-left-radius: 0.5rem; + border-bottom-right-radius: 0.5rem; +} + +.quill-editor-container .ql-editor { + min-height: 120px; + color: rgb(30, 41, 59); /* slate-800 */ +} + +.quill-editor-container .ql-editor.ql-blank::before { + color: rgb(100, 116, 139); /* slate-500 */ +} + +.ql-editor { + + /* 代码块容器 */ + .ql-code-block-container { + background: #1e293b; + color: #e2e8f0; + border-radius: 0.5rem; + margin: 0; /* 更新 */ + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + monospace; + + /* 代码块内容 */ + .ql-code-block { + padding: 0.2rem; + font-size: 0.875rem; + line-height: 1.2; + overflow-x: auto; + white-space: pre-wrap; + word-wrap: break-word; + } + } + + /* 代码块 */ + pre.ql-syntax { + background: #1e293b; + color: #e2e8f0; + border-radius: 0.5rem; + padding: 1rem; + margin: 0; /* 更新 */ + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + monospace; + font-size: 0.875rem; + line-height: 1.5; + overflow-x: auto; + white-space: pre-wrap; + word-wrap: break-word; + } + + /* 引用块 */ + blockquote { + border-left: 4px solid #3b82f6; + background: #f8fafc; + padding: 1rem 1.2rem; + margin: 0; /* 更新 */ + color: #475569; + font-style: italic; + + /* 嵌套引用 */ + blockquote { + border-left-color: #64748b; + background: #f1f5f9; + margin: 0; /* 更新 */ + } + } + + /* 有序列表 */ + ol { + list-style-type: decimal; + padding-left: 2rem; + margin: 0; /* 更新 */ + + /* 嵌套有序列表 */ + ol { + list-style-type: lower-alpha; + margin: 0; /* 更新 */ + + ol { + list-style-type: lower-roman; + } + } + + li { + padding-left: 0.5rem; + margin-bottom: 0; /* 更新 */ + + &::marker { + color: #3b82f6; + font-weight: 600; + } + } + } + + /* 无序列表 */ + ul { + list-style-type: disc; + padding-left: 2rem; + margin: 0; /* 更新 */ + + /* 嵌套无序列表 */ + ul { + list-style-type: circle; + margin: 0; /* 更新 */ + + ul { + list-style-type: square; + } + } + + li { + padding-left: 0.5rem; + margin-bottom: 0; /* 更新 */ + + &::marker { + color: #3b82f6; + } + } + } + + /* 标题 */ + h1, + h2, + h3 { + color: #1e3a8a; + font-weight: 600; + line-height: 1.25; + margin: 0; /* 更新 */ + } + + h1 { + font-size: 2em; + border-bottom: 2px solid #e2e8f0; + padding-bottom: 0.5rem; + } + + h2 { + font-size: 1.5em; + } + + h3 { + font-size: 1.25em; + } + + /* 分割线 */ + hr { + border: 0; + border-top: 2px solid #e2e8f0; + margin: 0; /* 更新 */ + } + + /* 段落 */ + p { + margin: 0; /* 更新 */ + line-height: 1.2; + } + + /* 表格 */ + table { + border-collapse: collapse; + width: 100%; + margin: 0; /* 更新 */ + + th, + td { + border: 1px solid #e2e8f0; + padding: 0.75rem; + text-align: left; + } + + th { + background: #f8fafc; + font-weight: 600; + } + + tr:nth-child(even) { + background: #f8fafc; + } + } +} diff --git a/packages/client/src/api/hooks/useVisitor.ts b/packages/client/src/api/hooks/useVisitor.ts index 7d1954d..842bb60 100644 --- a/packages/client/src/api/hooks/useVisitor.ts +++ b/packages/client/src/api/hooks/useVisitor.ts @@ -1,14 +1,14 @@ 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 +19,71 @@ 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 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(); + console.log("paramsList.length", paramsList.length); + // 遍历所有参数列表,执行乐观更新 + 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, - // }, - // }); - // }, + return { previousDataList }; + }, + // 错误处理:数据回滚 + 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 +93,13 @@ export function useVisitor() { readed: true, })) ); + const like = api.visitor.create.useMutation( + createOptimisticMutation((item) => ({ + ...item, + likes: (item.likes || 0) + 1, + liked: true, + })) + ); const addStar = api.visitor.create.useMutation( createOptimisticMutation((item) => ({ @@ -120,12 +130,13 @@ export function useVisitor() { }); return { - troubleParams, + postParams, create, createMany, deleteMany, read, addStar, deleteStar, + like, }; } diff --git a/packages/client/src/singleton/DataHolder.ts b/packages/client/src/singleton/DataHolder.ts index 977b195..5184548 100644 --- a/packages/client/src/singleton/DataHolder.ts +++ b/packages/client/src/singleton/DataHolder.ts @@ -1,36 +1,53 @@ -export class TroubleParams { - private static instance: TroubleParams; // 静态私有变量,用于存储单例实例 - private troubleParams: Array; // 私有数组属性,用于存储对象 +export class PostParams { + private static instance: PostParams; // 静态私有变量,用于存储单例实例 + private postParams: Array; // 私有数组属性,用于存储对象 private constructor() { - this.troubleParams = []; // 初始化空数组 + this.postParams = []; // 初始化空数组 } - public static getInstance(): TroubleParams { - if (!TroubleParams.instance) { - TroubleParams.instance = new TroubleParams(); + public static getInstance(): PostParams { + if (!PostParams.instance) { + PostParams.instance = new PostParams(); } - return TroubleParams.instance; + return PostParams.instance; } public addItem(item: object): void { - // 代码意图解析: 向数组中添加一个对象,确保不会添加重复的对象。 - // 技术原理阐述: 在添加对象之前,使用 `some` 方法检查数组中是否已经存在相同的对象。如果不存在,则添加到数组中。 - // 数据结构解读: `some` 方法遍历数组,检查是否存在满足条件的元素。`JSON.stringify` 用于将对象转换为字符串进行比较。 - // 算法复杂度分析: `some` 方法的复杂度为 O(n),因为需要遍历数组中的每个元素。`JSON.stringify` 的复杂度取决于对象的大小,通常为 O(m),其中 m 是对象的属性数量。因此,总复杂度为 O(n * m)。 - // 可能的优化建议: 如果数组非常大,可以考虑使用哈希表(如 `Map` 或 `Set`)来存储对象的唯一标识符,以提高查找效率。 - - const isDuplicate = this.troubleParams.some( - (existingItem) => - JSON.stringify(existingItem) === JSON.stringify(item) - ); + // 使用更可靠的方式比较查询参数 + const isDuplicate = this.postParams.some((existingItem: any) => { + if (item && existingItem) { + const itemWhere = (item as any).where; + const existingWhere = existingItem.where; + return ( + itemWhere?.parentId === existingWhere?.parentId && + itemWhere?.type === existingWhere?.type + ); + } + return false; + }); if (!isDuplicate) { - this.troubleParams.push(item); + this.postParams.push(item); } } + public removeItem(item: object): void { + // 使用相同的比较逻辑移除项 + this.postParams = this.postParams.filter((existingItem: any) => { + if (item && existingItem) { + const itemWhere = (item as any).where; + const existingWhere = existingItem.where; + return !( + itemWhere?.parentId === existingWhere?.parentId && + itemWhere?.type === existingWhere?.type + ); + } + return true; + }); + } + public getItems(): Array { - return [...this.troubleParams]; // 返回数组的副本,防止外部直接修改原数组 + return [...this.postParams]; } } diff --git a/packages/common/prisma/schema.prisma b/packages/common/prisma/schema.prisma index 077f689..69454ab 100644 --- a/packages/common/prisma/schema.prisma +++ b/packages/common/prisma/schema.prisma @@ -187,6 +187,7 @@ model Post { // 字符串类型字段 id String @id @default(cuid()) // 帖子唯一标识,使用 cuid() 生成默认值 type String? // 帖子类型,可为空 + state String? // 状态 : 未读、处理中、已回答 title String? // 帖子标题,可为空 content String? // 帖子内容,可为空 domainId String? @map("domain_id") @@ -194,12 +195,15 @@ model Post { termId String? @map("term_id") // 日期时间类型字段 createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + updatedAt DateTime @map("updated_at") deletedAt DateTime? @map("deleted_at") // 删除时间,可为空 // 关系类型字段 authorId String? @map("author_id") author Staff? @relation("post_author", fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型 visits Visit[] // 访问记录,关联 Visit 模型 + views Int @default(0) + likes Int @default(0) + receivers Staff[] @relation("post_receiver") parentId String? @map("parent_id") parent Post? @relation("PostChildren", fields: [parentId], references: [id]) // 父级帖子,关联 Post 模型 @@ -242,8 +246,8 @@ model Visit { views Int @default(1) @map("views") // sourceIP String? @map("source_ip") // 关联关系 - visitorId String @map("visitor_id") - visitor Staff @relation(fields: [visitorId], references: [id]) + visitorId String? @map("visitor_id") + visitor Staff? @relation(fields: [visitorId], references: [id]) postId String? @map("post_id") post Post? @relation(fields: [postId], references: [id]) message Message? @relation(fields: [messageId], references: [id]) diff --git a/packages/common/src/enum.ts b/packages/common/src/enum.ts index 92084ab..0e0815f 100755 --- a/packages/common/src/enum.ts +++ b/packages/common/src/enum.ts @@ -4,7 +4,7 @@ export enum SocketMsgType { export enum PostType { POST = "post", POST_COMMENT = "post_comment", - COURSE_REVIEW = "course_review" + COURSE_REVIEW = "course_review", } export enum TaxonomySlug { CATEGORY = "category", @@ -14,24 +14,24 @@ export enum TaxonomySlug { export enum VisitType { STAR = "star", READED = "read", + LIKE = "like", } - export enum StorageProvider { - LOCAL = 'LOCAL', - S3 = 'S3', - OSS = 'OSS', - COS = 'COS', - CDN = 'CDN' + LOCAL = "LOCAL", + S3 = "S3", + OSS = "OSS", + COS = "COS", + CDN = "CDN", } export enum ResourceStatus { UPLOADING = "UPLOADING", UPLOADED = "UPLOADED", - PROCESS_PENDING = 'PROCESS_PENDING', - PROCESSING = 'PROCESSING', - PROCESSED = 'PROCESSED', - PROCESS_FAILED = 'PROCESS_FAILED' + PROCESS_PENDING = "PROCESS_PENDING", + PROCESSING = "PROCESSING", + PROCESSED = "PROCESSED", + PROCESS_FAILED = "PROCESS_FAILED", } export enum ObjectType { DEPARTMENT = "department", @@ -48,7 +48,7 @@ export enum ObjectType { SECTION = "section", LECTURE = "lecture", ENROLLMENT = "enrollment", - RESOURCE = "resource" + RESOURCE = "resource", } export enum RolePerms { // Create Permissions 创建权限 @@ -94,100 +94,99 @@ export enum AppConfigSlug { } // 资源类型的枚举,定义了不同类型的资源,以字符串值表示 export enum ResourceType { - VIDEO = "video", // 视频资源 - PDF = "pdf", // PDF文档 - DOC = "doc", // Word文档 - EXCEL = "excel", // Excel表格 - PPT = "ppt", // PowerPoint演示文稿 - CODE = "code", // 代码文件 - LINK = "link", // 超链接 - IMAGE = "image", // 图片资源 - AUDIO = "audio", // 音频资源 - ZIP = "zip", // 压缩包文件 - OTHER = "other" // 其他未分类资源 + VIDEO = "video", // 视频资源 + PDF = "pdf", // PDF文档 + DOC = "doc", // Word文档 + EXCEL = "excel", // Excel表格 + PPT = "ppt", // PowerPoint演示文稿 + CODE = "code", // 代码文件 + LINK = "link", // 超链接 + IMAGE = "image", // 图片资源 + AUDIO = "audio", // 音频资源 + ZIP = "zip", // 压缩包文件 + OTHER = "other", // 其他未分类资源 } // 课程等级的枚举,描述了不同学习水平的课程 export enum CourseLevel { - BEGINNER = "beginner", // 初级课程,适合初学者 - INTERMEDIATE = "intermediate", // 中级课程,适合有一定基础的学习者 - ADVANCED = "advanced", // 高级课程,适合高级水平学习者 - ALL_LEVELS = "all_levels" // 适用于所有学习水平的课程 + BEGINNER = "beginner", // 初级课程,适合初学者 + INTERMEDIATE = "intermediate", // 中级课程,适合有一定基础的学习者 + ADVANCED = "advanced", // 高级课程,适合高级水平学习者 + ALL_LEVELS = "all_levels", // 适用于所有学习水平的课程 } // 课时(课程内容)类型的枚举,定义了课程中可能包含的不同内容形式 export enum LessonType { - VIDEO = "video", // 视频课程 - ARTICLE = "article", // 文章型课程内容 - QUIZ = "quiz", // 测验类型 + VIDEO = "video", // 视频课程 + ARTICLE = "article", // 文章型课程内容 + QUIZ = "quiz", // 测验类型 ASSIGNMENT = "assignment", // 作业类型 } // 课程状态的枚举,定义了课程生命周期中的各个状态 export enum CourseStatus { - DRAFT = "draft", // 草稿状态的课程,尚未发布 - UNDER_REVIEW = "under_review", // 正在审核中的课程 - PUBLISHED = "published", // 已发布的课程,可以被学员报名学习 - ARCHIVED = "archived" // 已归档的课程,不再对外展示 + DRAFT = "draft", // 草稿状态的课程,尚未发布 + UNDER_REVIEW = "under_review", // 正在审核中的课程 + PUBLISHED = "published", // 已发布的课程,可以被学员报名学习 + ARCHIVED = "archived", // 已归档的课程,不再对外展示 } export const CourseStatusLabel: Record = { [CourseStatus.DRAFT]: "草稿", [CourseStatus.UNDER_REVIEW]: "审核中", [CourseStatus.PUBLISHED]: "已发布", - [CourseStatus.ARCHIVED]: "已归档" + [CourseStatus.ARCHIVED]: "已归档", }; // 报名状态的枚举,描述了用户报名参加课程的不同状态 export enum EnrollmentStatus { - PENDING = "pending", // 报名待处理状态 - ACTIVE = "active", // 活跃状态,用户可参与课程 + PENDING = "pending", // 报名待处理状态 + ACTIVE = "active", // 活跃状态,用户可参与课程 COMPLETED = "completed", // 完成状态,用户已完成课程 CANCELLED = "cancelled", // 已取消的报名 - REFUNDED = "refunded" // 已退款的报名 + REFUNDED = "refunded", // 已退款的报名 } // 授课角色的枚举,定义了讲师在课程中的角色分配 export enum InstructorRole { - MAIN = "main", // 主讲教师 - ASSISTANT = "assistant" // 助教 + MAIN = "main", // 主讲教师 + ASSISTANT = "assistant", // 助教 } export const EnrollmentStatusLabel = { - [EnrollmentStatus.PENDING]: '待处理', - [EnrollmentStatus.ACTIVE]: '进行中', - [EnrollmentStatus.COMPLETED]: '已完成', - [EnrollmentStatus.CANCELLED]: '已取消', - [EnrollmentStatus.REFUNDED]: '已退款' + [EnrollmentStatus.PENDING]: "待处理", + [EnrollmentStatus.ACTIVE]: "进行中", + [EnrollmentStatus.COMPLETED]: "已完成", + [EnrollmentStatus.CANCELLED]: "已取消", + [EnrollmentStatus.REFUNDED]: "已退款", }; export const InstructorRoleLabel = { - [InstructorRole.MAIN]: '主讲教师', - [InstructorRole.ASSISTANT]: '助教' + [InstructorRole.MAIN]: "主讲教师", + [InstructorRole.ASSISTANT]: "助教", }; export const ResourceTypeLabel = { - [ResourceType.VIDEO]: '视频', - [ResourceType.PDF]: 'PDF文档', - [ResourceType.DOC]: 'Word文档', - [ResourceType.EXCEL]: 'Excel表格', - [ResourceType.PPT]: 'PPT演示文稿', - [ResourceType.CODE]: '代码文件', - [ResourceType.LINK]: '链接', - [ResourceType.IMAGE]: '图片', - [ResourceType.AUDIO]: '音频', - [ResourceType.ZIP]: '压缩包', - [ResourceType.OTHER]: '其他' + [ResourceType.VIDEO]: "视频", + [ResourceType.PDF]: "PDF文档", + [ResourceType.DOC]: "Word文档", + [ResourceType.EXCEL]: "Excel表格", + [ResourceType.PPT]: "PPT演示文稿", + [ResourceType.CODE]: "代码文件", + [ResourceType.LINK]: "链接", + [ResourceType.IMAGE]: "图片", + [ResourceType.AUDIO]: "音频", + [ResourceType.ZIP]: "压缩包", + [ResourceType.OTHER]: "其他", }; export const CourseLevelLabel = { - [CourseLevel.BEGINNER]: '初级', - [CourseLevel.INTERMEDIATE]: '中级', - [CourseLevel.ADVANCED]: '高级', - [CourseLevel.ALL_LEVELS]: '不限级别' + [CourseLevel.BEGINNER]: "初级", + [CourseLevel.INTERMEDIATE]: "中级", + [CourseLevel.ADVANCED]: "高级", + [CourseLevel.ALL_LEVELS]: "不限级别", }; export const LessonTypeLabel = { - [LessonType.VIDEO]: '视频课程', - [LessonType.ARTICLE]: '图文课程', - [LessonType.QUIZ]: '测验', - [LessonType.ASSIGNMENT]: '作业' + [LessonType.VIDEO]: "视频课程", + [LessonType.ARTICLE]: "图文课程", + [LessonType.QUIZ]: "测验", + [LessonType.ASSIGNMENT]: "作业", }; - diff --git a/packages/common/src/select.ts b/packages/common/src/select.ts index 4f91ed1..a82eb9f 100644 --- a/packages/common/src/select.ts +++ b/packages/common/src/select.ts @@ -1,2 +1,46 @@ import { Prisma } from "@prisma/client"; +export const postDetailSelect: Prisma.PostSelect = { + id: true, + type: true, + title: true, + content: true, + views: true, + likes: true, + resources: true, + createdAt: true, + updatedAt: true, + termId: true, + term: { + include: { + taxonomy: true, + }, + }, + author: { + select: { + id: true, + showname: true, + avatar: true, + department: { + select: { + id: true, + name: true, + }, + }, + }, + }, + receivers: { + select: { + id: true, + showname: true, + avatar: true, + department: { + select: { + id: true, + name: true, + }, + }, + }, + }, + meta: true, +}; diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index b20b146..0c35b89 100755 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -5,6 +5,7 @@ import type { Message, Post, RoleMap, + Resource, } from "@prisma/client"; import { SocketMsgType, RolePerms } from "./enum"; import { RowRequestSchema } from "./schema"; @@ -125,16 +126,20 @@ export type PostComment = { }; export type PostDto = Post & { readed: boolean; + liked: boolean; readedCount: number; - author: StaffDto; - limitedComments: PostComment[]; commentsCount: number; + term: TermDto; + author: StaffDto | undefined; + receivers: StaffDto[]; + resources: Resource[]; perms?: { delete: boolean; // edit: boolean; }; - watchableDepts: Department[]; - watchableStaffs: Staff[]; + + views: number; + meta?: PostMeta; }; export type TermDto = Term & { @@ -161,6 +166,7 @@ export interface BaseSetting { export interface PostMeta { signature?: string; ip?: string; + tags?: string[]; } export type RowModelResult = { rowData: any[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e17cfaa..8ff2e29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,7 @@ importers: dependencies: '@nestjs/bullmq': specifier: ^10.2.0 - version: 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(bullmq@5.34.8) + version: 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(bullmq@5.34.8) '@nestjs/common': specifier: ^10.3.10 version: 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -33,7 +33,7 @@ importers: version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.15)(rxjs@7.8.1) '@nestjs/schedule': specifier: ^4.1.0 - version: 4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15) + version: 4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)) '@nestjs/websockets': specifier: ^10.3.10 version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-socket.io@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -148,7 +148,7 @@ importers: version: 10.2.3(chokidar@3.6.0)(typescript@5.7.2) '@nestjs/testing': specifier: ^10.0.0 - version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-express@10.4.15) + version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)) '@types/exceljs': specifier: ^1.3.0 version: 1.3.2 @@ -386,6 +386,9 @@ importers: react-hot-toast: specifier: ^2.4.1 version: 2.5.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-intersection-observer: + specifier: ^9.15.1 + version: 9.15.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react-resizable: specifier: ^3.0.5 version: 3.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -5946,6 +5949,15 @@ packages: react: '>=16.8.1' react-dom: '>=16.8.1' + react-intersection-observer@9.15.1: + resolution: {integrity: sha512-vGrqYEVWXfH+AGu241uzfUpNK4HAdhCkSAyFdkMb9VWWXs6mxzBLpWCxEy9YcnDNY2g9eO6z7qUtTBdA9hc8pA==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react-dom: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -8538,15 +8550,15 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true - '@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)': + '@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))': dependencies: '@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) tslib: 2.8.1 - '@nestjs/bullmq@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(bullmq@5.34.8)': + '@nestjs/bullmq@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(bullmq@5.34.8)': dependencies: - '@nestjs/bull-shared': 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15) + '@nestjs/bull-shared': 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)) '@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) bullmq: 5.34.8 @@ -8643,7 +8655,7 @@ snapshots: - supports-color - utf-8-validate - '@nestjs/schedule@4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)': + '@nestjs/schedule@4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))': dependencies: '@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -8661,7 +8673,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-express@10.4.15)': + '@nestjs/testing@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15))': dependencies: '@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -13285,6 +13297,12 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + react-intersection-observer@9.15.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + react: 18.2.0 + optionalDependencies: + react-dom: 18.2.0(react@18.2.0) + react-is@16.13.1: {} react-is@17.0.2: {}