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 1f16da9..efe8d7d 100755
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -65,6 +65,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 ed12695..cb6c10e 100644
--- a/apps/web/src/app/main/letter/write/header.tsx
+++ b/apps/web/src/app/main/letter/write/header.tsx
@@ -11,30 +11,54 @@ export default function Header() {
- {/* 隐私保护说明 */}
-
+ {/* 隐私保护说明 */}
+
{/* 隐私承诺 */}
@@ -43,4 +67,4 @@ export default function Header() {
-}
\ 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 2b31cd4..513d686 100644
--- a/apps/web/src/components/models/post/LetterEditor/form/LetterBasicForm.tsx
+++ b/apps/web/src/components/models/post/LetterEditor/form/LetterBasicForm.tsx
@@ -59,8 +59,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 (
+
+
+
+ );
+}
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 d6f3d20..c94b047 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;
}
@@ -85,7 +85,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;
}
@@ -98,7 +100,7 @@
display: none !important;
}
-.no-wrap-header .ant-table-thead>tr>th {
+.no-wrap-header .ant-table-thead > tr > th {
white-space: nowrap;
}
@@ -114,12 +116,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;
/* 去除最后一行的底部边框 */
-}
\ No newline at end of file
+}
+.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