Merge branch 'main' of http://113.45.157.195:3003/insiinc/leader-mail
This commit is contained in:
commit
a71a88c30e
|
@ -27,8 +27,10 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
|
||||||
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
|
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
|
||||||
) {
|
) {
|
||||||
args.data.authorId = params?.staff?.id;
|
args.data.authorId = params?.staff?.id;
|
||||||
|
args.data.updatedAt = new Date();
|
||||||
// args.data.resources
|
// args.data.resources
|
||||||
const result = await super.create(args);
|
const result = await super.create(args);
|
||||||
|
await this.updateParentTimestamp(result?.parentId);
|
||||||
EventBus.emit('dataChanged', {
|
EventBus.emit('dataChanged', {
|
||||||
type: ObjectType.POST,
|
type: ObjectType.POST,
|
||||||
operation: CrudOperation.CREATED,
|
operation: CrudOperation.CREATED,
|
||||||
|
@ -38,6 +40,7 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
|
||||||
}
|
}
|
||||||
async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) {
|
async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) {
|
||||||
args.data.authorId = staff?.id;
|
args.data.authorId = staff?.id;
|
||||||
|
args.data.updatedAt = new Date();
|
||||||
const result = await super.update(args);
|
const result = await super.update(args);
|
||||||
EventBus.emit('dataChanged', {
|
EventBus.emit('dataChanged', {
|
||||||
type: ObjectType.POST,
|
type: ObjectType.POST,
|
||||||
|
@ -46,6 +49,17 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
|
||||||
});
|
});
|
||||||
return result;
|
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) {
|
async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
|
||||||
if (!args.where) args.where = {};
|
if (!args.where) args.where = {};
|
||||||
args.where.OR = await this.preFilter(args.where.OR, staff);
|
args.where.OR = await this.preFilter(args.where.OR, staff);
|
||||||
|
@ -125,4 +139,20 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
|
||||||
if (orCondition?.length > 0) return orCondition;
|
if (orCondition?.length > 0) return orCondition;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 更新父帖子的时间戳
|
||||||
|
* 当子帖子被创建时,自动更新父帖子的更新时间
|
||||||
|
* @param parentId 父帖子的ID
|
||||||
|
*/
|
||||||
|
async updateParentTimestamp(parentId: string | undefined) {
|
||||||
|
if (!parentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.update({
|
||||||
|
where: {
|
||||||
|
id: parentId,
|
||||||
|
},
|
||||||
|
data: {}, // 空对象会自动更新 updatedAt 时间戳
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,16 +5,9 @@ export async function setPostRelation(params: {
|
||||||
staff?: UserProfile;
|
staff?: UserProfile;
|
||||||
}) {
|
}) {
|
||||||
const { data, staff } = params;
|
const { data, staff } = params;
|
||||||
const limitedComments = await db.post.findMany({
|
// 在函数开始时计算一次时间
|
||||||
where: {
|
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
|
||||||
parentId: data.id,
|
const clientIp = (data?.meta as any)?.ip;
|
||||||
type: PostType.POST_COMMENT,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
author: true,
|
|
||||||
},
|
|
||||||
take: 5,
|
|
||||||
});
|
|
||||||
const commentsCount = await db.post.count({
|
const commentsCount = await db.post.count({
|
||||||
where: {
|
where: {
|
||||||
parentId: data.id,
|
parentId: data.id,
|
||||||
|
@ -29,6 +22,23 @@ export async function setPostRelation(params: {
|
||||||
visitorId: staff?.id,
|
visitorId: staff?.id,
|
||||||
},
|
},
|
||||||
})) > 0;
|
})) > 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({
|
const readedCount = await db.visit.count({
|
||||||
where: {
|
where: {
|
||||||
postId: data.id,
|
postId: data.id,
|
||||||
|
@ -39,7 +49,8 @@ export async function setPostRelation(params: {
|
||||||
Object.assign(data, {
|
Object.assign(data, {
|
||||||
readed,
|
readed,
|
||||||
readedCount,
|
readedCount,
|
||||||
limitedComments,
|
liked,
|
||||||
|
// limitedComments,
|
||||||
commentsCount,
|
commentsCount,
|
||||||
// trouble
|
// trouble
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 || '';
|
||||||
|
}
|
|
@ -4,23 +4,36 @@ import { Prisma } from '@nice/common';
|
||||||
|
|
||||||
import { VisitService } from './visit.service';
|
import { VisitService } from './visit.service';
|
||||||
import { z, ZodType } from 'zod';
|
import { z, ZodType } from 'zod';
|
||||||
const VisitCreateArgsSchema: ZodType<Prisma.VisitCreateArgs> = z.any()
|
import { getClientIp } from './utils';
|
||||||
const VisitCreateManyInputSchema: ZodType<Prisma.VisitCreateManyInput> = z.any()
|
const VisitCreateArgsSchema: ZodType<Prisma.VisitCreateArgs> = z.any();
|
||||||
const VisitDeleteManyArgsSchema: ZodType<Prisma.VisitDeleteManyArgs> = z.any()
|
const VisitCreateManyInputSchema: ZodType<Prisma.VisitCreateManyInput> =
|
||||||
|
z.any();
|
||||||
|
const VisitDeleteManyArgsSchema: ZodType<Prisma.VisitDeleteManyArgs> = z.any();
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VisitRouter {
|
export class VisitRouter {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly trpc: TrpcService,
|
private readonly trpc: TrpcService,
|
||||||
private readonly visitService: VisitService,
|
private readonly visitService: VisitService,
|
||||||
) { }
|
) {}
|
||||||
router = this.trpc.router({
|
router = this.trpc.router({
|
||||||
create: this.trpc.protectProcedure
|
create: this.trpc.protectProcedure
|
||||||
.input(VisitCreateArgsSchema)
|
.input(VisitCreateArgsSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { staff } = ctx;
|
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);
|
return await this.visitService.create(input, staff);
|
||||||
}),
|
}),
|
||||||
createMany: this.trpc.protectProcedure.input(z.array(VisitCreateManyInputSchema))
|
createMany: this.trpc.protectProcedure
|
||||||
|
.input(z.array(VisitCreateManyInputSchema))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { staff } = ctx;
|
const { staff } = ctx;
|
||||||
|
|
||||||
|
@ -31,7 +44,5 @@ export class VisitRouter {
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return await this.visitService.deleteMany(input);
|
return await this.visitService.deleteMany(input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,13 +9,28 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
|
||||||
}
|
}
|
||||||
async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) {
|
async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) {
|
||||||
const { postId, messageId } = args.data;
|
const { postId, messageId } = args.data;
|
||||||
|
const clientIp = (args.data.meta as any)?.ip;
|
||||||
|
console.log('visit create');
|
||||||
const visitorId = args.data.visitorId || staff?.id;
|
const visitorId = args.data.visitorId || staff?.id;
|
||||||
let result;
|
let result;
|
||||||
const existingVisit = await db.visit.findFirst({
|
const existingVisit = await db.visit.findFirst({
|
||||||
where: {
|
where: {
|
||||||
type: args.data.type,
|
type: args.data.type,
|
||||||
visitorId,
|
OR: [
|
||||||
OR: [{ postId }, { messageId }],
|
{
|
||||||
|
AND: [
|
||||||
|
{ OR: [{ postId }, { messageId }] },
|
||||||
|
{ visitorId: visitorId || null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{ OR: [{ postId }, { messageId }] },
|
||||||
|
{ visitorId: null },
|
||||||
|
{ meta: { path: ['ip'], equals: clientIp } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!existingVisit) {
|
if (!existingVisit) {
|
||||||
|
@ -28,14 +43,36 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
|
||||||
views: existingVisit.views + 1,
|
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) {
|
if (postId && args.data.type === VisitType.READED) {
|
||||||
// EventBus.emit('updateViewCount', {
|
EventBus.emit('updateVisitCount', {
|
||||||
// objectType: ObjectType.TROUBLE,
|
objectType: ObjectType.POST,
|
||||||
// id: troubleId,
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) {
|
async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) {
|
||||||
|
|
|
@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,16 +1,12 @@
|
||||||
import { InjectQueue } from "@nestjs/bullmq";
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from '@nestjs/common';
|
||||||
import EventBus from "@server/utils/event-bus";
|
import EventBus from '@server/utils/event-bus';
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from 'bullmq';
|
||||||
import { ObjectType } from "@nice/common";
|
import { ObjectType } from '@nice/common';
|
||||||
import { QueueJobType } from "../types";
|
import { QueueJobType } from '../types';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PostProcessService {
|
export class PostProcessService {
|
||||||
constructor(
|
constructor(@InjectQueue('general') private generalQueue: Queue) {}
|
||||||
@InjectQueue('general') private generalQueue: Queue
|
|
||||||
) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateJobId(type: ObjectType, data: any): string {
|
private generateJobId(type: ObjectType, data: any): string {
|
||||||
// 根据类型和相关ID生成唯一的job标识
|
// 根据类型和相关ID生成唯一的job标识
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { BullModule } from '@nestjs/bullmq';
|
||||||
import { Logger, Module } from '@nestjs/common';
|
import { Logger, Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { PostQueueService } from './models/post/post.queue.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -25,11 +26,10 @@ import { join } from 'path';
|
||||||
{
|
{
|
||||||
name: 'file-queue', // 新增文件处理队列
|
name: 'file-queue', // 新增文件处理队列
|
||||||
processors: [join(__dirname, 'worker/file.processor.js')], // 文件处理器的路径
|
processors: [join(__dirname, 'worker/file.processor.js')], // 文件处理器的路径
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
providers: [Logger],
|
providers: [Logger, PostQueueService],
|
||||||
exports: []
|
exports: [],
|
||||||
|
|
||||||
})
|
})
|
||||||
export class QueueModule { }
|
export class QueueModule {}
|
||||||
|
|
|
@ -1,18 +1,16 @@
|
||||||
import { InjectQueue } from "@nestjs/bullmq";
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from '@nestjs/common';
|
||||||
import EventBus from "@server/utils/event-bus";
|
import EventBus from '@server/utils/event-bus';
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from 'bullmq';
|
||||||
import { ObjectType } from "@nice/common";
|
import { ObjectType } from '@nice/common';
|
||||||
import { QueueJobType } from "../types";
|
import { QueueJobType } from '../types';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StatsService {
|
export class StatsService {
|
||||||
constructor(
|
constructor(@InjectQueue('general') private generalQueue: Queue) {
|
||||||
@InjectQueue('general') private generalQueue: Queue
|
EventBus.on('dataChanged', async ({ type, data }) => {
|
||||||
) {
|
|
||||||
EventBus.on("dataChanged", async ({ type, data }) => {
|
|
||||||
const jobOptions = {
|
const jobOptions = {
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
jobId: this.generateJobId(type as ObjectType, data) // 使用唯一ID防止重复任务
|
jobId: this.generateJobId(type as ObjectType, data), // 使用唯一ID防止重复任务
|
||||||
};
|
};
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ObjectType.ENROLLMENT:
|
case ObjectType.ENROLLMENT:
|
||||||
|
@ -20,9 +18,9 @@ export class StatsService {
|
||||||
QueueJobType.UPDATE_STATS,
|
QueueJobType.UPDATE_STATS,
|
||||||
{
|
{
|
||||||
courseId: data.courseId,
|
courseId: data.courseId,
|
||||||
type: ObjectType.ENROLLMENT
|
type: ObjectType.ENROLLMENT,
|
||||||
},
|
},
|
||||||
jobOptions
|
jobOptions,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
@ -32,9 +30,9 @@ export class StatsService {
|
||||||
{
|
{
|
||||||
sectionId: data.sectionId,
|
sectionId: data.sectionId,
|
||||||
courseId: data.courseId,
|
courseId: data.courseId,
|
||||||
type: ObjectType.LECTURE
|
type: ObjectType.LECTURE,
|
||||||
},
|
},
|
||||||
jobOptions
|
jobOptions,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
@ -44,9 +42,9 @@ export class StatsService {
|
||||||
QueueJobType.UPDATE_STATS,
|
QueueJobType.UPDATE_STATS,
|
||||||
{
|
{
|
||||||
courseId: data.courseId,
|
courseId: data.courseId,
|
||||||
type: ObjectType.POST
|
type: ObjectType.POST,
|
||||||
},
|
},
|
||||||
jobOptions
|
jobOptions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
|
import { VisitType } from 'packages/common/dist';
|
||||||
export enum QueueJobType {
|
export enum QueueJobType {
|
||||||
UPDATE_STATS = "update_stats",
|
UPDATE_STATS = 'update_stats',
|
||||||
FILE_PROCESS = "file_process"
|
FILE_PROCESS = 'file_process',
|
||||||
|
UPDATE_POST_VISIT_COUNT = 'updatePostVisitCount',
|
||||||
}
|
}
|
||||||
|
export type updateVisitCountJobData = {
|
||||||
|
id: string;
|
||||||
|
type: VisitType;
|
||||||
|
};
|
||||||
|
|
|
@ -7,10 +7,10 @@ import { VideoProcessor } from '@server/models/resource/processor/VideoProcessor
|
||||||
const logger = new Logger('FileProcessorWorker');
|
const logger = new Logger('FileProcessorWorker');
|
||||||
const pipeline = new ResourceProcessingPipeline()
|
const pipeline = new ResourceProcessingPipeline()
|
||||||
.addProcessor(new ImageProcessor())
|
.addProcessor(new ImageProcessor())
|
||||||
.addProcessor(new VideoProcessor())
|
.addProcessor(new VideoProcessor());
|
||||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||||
if (job.name === QueueJobType.FILE_PROCESS) {
|
if (job.name === QueueJobType.FILE_PROCESS) {
|
||||||
console.log(job)
|
console.log(job);
|
||||||
const { resource } = job.data;
|
const { resource } = job.data;
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
throw new Error('No resource provided in job data');
|
throw new Error('No resource provided in job data');
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Logger } from '@nestjs/common';
|
||||||
import { ObjectType } from '@nice/common';
|
import { ObjectType } from '@nice/common';
|
||||||
|
|
||||||
import { QueueJobType } from '../types';
|
import { QueueJobType } from '../types';
|
||||||
|
import { updatePostViewCount } from '../models/post/utils';
|
||||||
const logger = new Logger('QueueWorker');
|
const logger = new Logger('QueueWorker');
|
||||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||||
try {
|
try {
|
||||||
|
@ -37,6 +38,9 @@ export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||||
`Updated course stats for courseId: ${courseId}, type: ${type}`,
|
`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) {
|
} catch (error: any) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error processing stats update job: ${error.message}`,
|
`Error processing stats update job: ${error.message}`,
|
||||||
|
|
|
@ -47,9 +47,9 @@ export class TrpcService {
|
||||||
|
|
||||||
// Define a protected procedure that ensures the user is authenticated
|
// Define a protected procedure that ensures the user is authenticated
|
||||||
protectProcedure = this.procedure.use(async ({ ctx, next }) => {
|
protectProcedure = this.procedure.use(async ({ ctx, next }) => {
|
||||||
if (!ctx?.staff) {
|
// if (!ctx?.staff) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: '未授权请求' });
|
// throw new TRPCError({ code: 'UNAUTHORIZED', message: '未授权请求' });
|
||||||
}
|
// }
|
||||||
return next({
|
return next({
|
||||||
ctx: {
|
ctx: {
|
||||||
// User value is confirmed to be non-null at this point
|
// User value is confirmed to be non-null at this point
|
||||||
|
|
|
@ -1,16 +1,25 @@
|
||||||
import mitt from 'mitt';
|
import mitt from 'mitt';
|
||||||
import { ObjectType, UserProfile, MessageDto } from '@nice/common';
|
import { ObjectType, UserProfile, MessageDto, VisitType } from '@nice/common';
|
||||||
export enum CrudOperation {
|
export enum CrudOperation {
|
||||||
CREATED,
|
CREATED,
|
||||||
UPDATED,
|
UPDATED,
|
||||||
DELETED
|
DELETED,
|
||||||
}
|
}
|
||||||
type Events = {
|
type Events = {
|
||||||
genDataEvent: { type: "start" | "end" },
|
genDataEvent: { type: 'start' | 'end' };
|
||||||
markDirty: { objectType: string, id: string, staff?: UserProfile, subscribers?: string[] }
|
markDirty: {
|
||||||
updateViewCount: { id: string, objectType: ObjectType },
|
objectType: string;
|
||||||
onMessageCreated: { data: Partial<MessageDto> },
|
id: string;
|
||||||
dataChanged: { type: string, operation: CrudOperation, data: any }
|
staff?: UserProfile;
|
||||||
|
subscribers?: string[];
|
||||||
|
};
|
||||||
|
updateVisitCount: {
|
||||||
|
id: string;
|
||||||
|
objectType: ObjectType;
|
||||||
|
visitType: VisitType;
|
||||||
|
};
|
||||||
|
onMessageCreated: { data: Partial<MessageDto> };
|
||||||
|
dataChanged: { type: string; operation: CrudOperation; data: any };
|
||||||
};
|
};
|
||||||
const EventBus = mitt<Events>();
|
const EventBus = mitt<Events>();
|
||||||
export default EventBus;
|
export default EventBus;
|
||||||
|
|
|
@ -65,6 +65,7 @@
|
||||||
"react-dropzone": "^14.3.5",
|
"react-dropzone": "^14.3.5",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"react-intersection-observer": "^9.15.1",
|
||||||
"react-resizable": "^3.0.5",
|
"react-resizable": "^3.0.5",
|
||||||
"react-router-dom": "^6.24.1",
|
"react-router-dom": "^6.24.1",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
|
import PostDetail from "@web/src/components/models/post/detail/PostDetail";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
export default function LetterDetailPage() {
|
export default function LetterDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
return (
|
return <PostDetail id={id}></PostDetail>;
|
||||||
<>
|
|
||||||
<div>{id}</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,25 +14,49 @@ export default function Header() {
|
||||||
{/* 隐私保护说明 */}
|
{/* 隐私保护说明 */}
|
||||||
<div className="flex flex-wrap gap-6 text-sm">
|
<div className="flex flex-wrap gap-6 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
className="w-5 h-5"
|
||||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>个人信息严格保密</span>
|
<span>个人信息严格保密</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
className="w-5 h-5"
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>支持匿名反映问题</span>
|
<span>支持匿名反映问题</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
className="w-5 h-5"
|
||||||
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" />
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>网络信息加密存储</span>
|
<span>网络数据加密存储</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
export const defaultModules = {
|
export const defaultModules = {
|
||||||
toolbar: [
|
toolbar: [
|
||||||
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
[{ header: [1, 2, 3, 4, 5, 6, false] }],
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
["bold", "italic", "underline", "strike"],
|
||||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
[{ list: "ordered" }, { list: "bullet" }],
|
||||||
[{ 'color': [] }, { 'background': [] }],
|
[{ color: [] }, { background: [] }],
|
||||||
[{ 'align': [] }],
|
[{ align: [] }],
|
||||||
['link', 'image'],
|
["link"],
|
||||||
['clean']
|
// ['link', 'image'],
|
||||||
]
|
["clean"],
|
||||||
|
],
|
||||||
};
|
};
|
|
@ -83,7 +83,7 @@ export function LetterFormProvider({
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// navigate(`/course/${result.id}/editor`, { replace: true });
|
navigate(`/course/${result.id}/detail`, { replace: true });
|
||||||
toast.success("发送成功!");
|
toast.success("发送成功!");
|
||||||
|
|
||||||
methods.reset(data);
|
methods.reset(data);
|
||||||
|
|
|
@ -59,8 +59,8 @@ export function LetterBasicForm() {
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
transition={{ duration: 0.5 }}>
|
transition={{ duration: 0.5 }}>
|
||||||
{/* 收件人 */}
|
{/* 收件人和板块信息行 */}
|
||||||
{
|
<div className="flex justify-start items-center gap-8">
|
||||||
<motion.div
|
<motion.div
|
||||||
custom={0}
|
custom={0}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
|
@ -70,9 +70,7 @@ export function LetterBasicForm() {
|
||||||
<UserIcon className="w-5 h-5 mr-2 text-[#00308F]" />
|
<UserIcon className="w-5 h-5 mr-2 text-[#00308F]" />
|
||||||
<div>收件人:{receiver?.showname}</div>
|
<div>收件人:{receiver?.showname}</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
}
|
|
||||||
{/* 选择板块 */}
|
|
||||||
{
|
|
||||||
<motion.div
|
<motion.div
|
||||||
custom={1}
|
custom={1}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
|
@ -82,7 +80,7 @@ export function LetterBasicForm() {
|
||||||
<FolderIcon className="w-5 h-5 mr-2 text-[#00308F]" />
|
<FolderIcon className="w-5 h-5 mr-2 text-[#00308F]" />
|
||||||
<div>板块:{term?.name}</div>
|
<div>板块:{term?.name}</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
}
|
</div>
|
||||||
{/* 主题输入框 */}
|
{/* 主题输入框 */}
|
||||||
<motion.div
|
<motion.div
|
||||||
custom={2}
|
custom={2}
|
||||||
|
@ -142,11 +140,11 @@ export function LetterBasicForm() {
|
||||||
className="flex justify-end">
|
className="flex justify-end">
|
||||||
<FormCheckbox
|
<FormCheckbox
|
||||||
name="isPublic"
|
name="isPublic"
|
||||||
label="公开信件"
|
label="是否公开"
|
||||||
defaultChecked
|
defaultChecked
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
{/*
|
||||||
<motion.div
|
<motion.div
|
||||||
custom={5}
|
custom={5}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
|
@ -155,13 +153,12 @@ export function LetterBasicForm() {
|
||||||
className="flex justify-end">
|
className="flex justify-end">
|
||||||
<FormSignature
|
<FormSignature
|
||||||
name="meta.signature"
|
name="meta.signature"
|
||||||
|
|
||||||
width="w-32"
|
width="w-32"
|
||||||
placeholder="添加个性签名"
|
placeholder="添加个性签名"
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
viewMode={false}
|
viewMode={false}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div> */}
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={handleSubmit(onSubmit)}
|
onClick={handleSubmit(onSubmit)}
|
||||||
|
@ -169,7 +166,7 @@ export function LetterBasicForm() {
|
||||||
transform transition-all duration-200 hover:scale-105 focus:ring-2 focus:ring-[#00308F]/50"
|
transform transition-all duration-200 hover:scale-105 focus:ring-2 focus:ring-[#00308F]/50"
|
||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}>
|
whileTap={{ scale: 0.98 }}>
|
||||||
发送信件
|
提交
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.form>
|
</motion.form>
|
||||||
|
|
|
@ -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"
|
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"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>网络信息加密存储</span>
|
<span>网络数据加密存储</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -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 (
|
||||||
|
<div className={`space-y-4 mt-6 ${className}`}>
|
||||||
|
{[...Array(count)].map((_, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.5,
|
||||||
|
delay: i * 0.1,
|
||||||
|
ease: [0.4, 0, 0.2, 1],
|
||||||
|
}}
|
||||||
|
className="relative overflow-hidden rounded-lg bg-white border border-slate-200 p-4">
|
||||||
|
{/* 闪光扫描效果 */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
animate={{
|
||||||
|
background: [
|
||||||
|
"linear-gradient(90deg, transparent 0%, rgba(0,48,143,0.05) 50%, transparent 100%)",
|
||||||
|
"linear-gradient(90deg, transparent 0%, rgba(0,48,143,0.05) 50%, transparent 100%)",
|
||||||
|
],
|
||||||
|
x: ["-100%", "100%"],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "linear",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 内容骨架 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<SkeletonItem
|
||||||
|
className="w-10 h-10 rounded-full"
|
||||||
|
delay={i * 0.1}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<SkeletonItem
|
||||||
|
className="h-4 w-1/4"
|
||||||
|
delay={i * 0.1 + 0.1}
|
||||||
|
/>
|
||||||
|
<SkeletonItem
|
||||||
|
className="h-3 w-1/6"
|
||||||
|
delay={i * 0.1 + 0.2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SkeletonItem
|
||||||
|
className="h-4 w-3/4"
|
||||||
|
delay={i * 0.1 + 0.3}
|
||||||
|
/>
|
||||||
|
<SkeletonItem
|
||||||
|
className="h-4 w-1/2"
|
||||||
|
delay={i * 0.1 + 0.4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-lg shadow-sm border border-slate-200 p-4"
|
||||||
|
layout>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Avatar
|
||||||
|
src={post.author?.avatar}
|
||||||
|
name={post.author?.showname || "匿名用户"}
|
||||||
|
size={40}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
style={{ height: 40 }}>
|
||||||
|
<span className="font-medium text-slate-900">
|
||||||
|
{post.author?.showname || "匿名用户"}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-slate-500">
|
||||||
|
{dayjs(post?.createdAt).format("YYYY-MM-DD HH:mm")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="ql-editor text-slate-800"
|
||||||
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: post.content || "" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 添加有帮助按钮 */}
|
||||||
|
<div className="mt-3 flex items-center">
|
||||||
|
<motion.button
|
||||||
|
onClick={likeThisPost}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
className={`inline-flex items-center space-x-1.5 px-3 py-1.5 rounded-full text-sm
|
||||||
|
${
|
||||||
|
post?.liked
|
||||||
|
? "bg-blue-50 text-blue-600"
|
||||||
|
: "hover:bg-slate-50 text-slate-600"
|
||||||
|
} transition-colors duration-200`}>
|
||||||
|
{post?.liked ? (
|
||||||
|
<HeartIconSolid className="h-4 w-4 text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<HeartIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span>{post?.likes || 0} 有帮助</span>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="w-full mx-auto mt-4">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div className="relative rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
|
{!isPreview ? (
|
||||||
|
<QuillEditor
|
||||||
|
value={content}
|
||||||
|
onChange={setContent}
|
||||||
|
placeholder="写下你的回复..."
|
||||||
|
className="bg-transparent"
|
||||||
|
theme="snow"
|
||||||
|
minRows={3}
|
||||||
|
maxRows={12}
|
||||||
|
modules={{
|
||||||
|
toolbar: [
|
||||||
|
["bold", "italic", "strike"],
|
||||||
|
["blockquote", "code-block"],
|
||||||
|
[{ list: "ordered" }, { list: "bullet" }],
|
||||||
|
["link"],
|
||||||
|
["clean"],
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--ql-border-color": "transparent",
|
||||||
|
"--ql-toolbar-bg": "rgb(248, 250, 252)", // slate-50
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="ql-editor p-4 min-h-[120px] text-slate-800 prose prose-slate max-w-none quill-editor-container"
|
||||||
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
{/* <motion.button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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"
|
||||||
|
}`}>
|
||||||
|
<CommandLineIcon className="w-5 h-5" />
|
||||||
|
<span>{isPreview ? "编辑" : "预览"}</span>
|
||||||
|
</motion.button> */}
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="submit"
|
||||||
|
disabled={isContentEmpty(content)}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
className={`flex items-center space-x-2 px-4 py-2 rounded-md
|
||||||
|
${
|
||||||
|
!isContentEmpty(content)
|
||||||
|
? "bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
: "bg-slate-100 text-slate-400 cursor-not-allowed"
|
||||||
|
} transition-colors`}>
|
||||||
|
<PaperAirplaneIcon className="w-4 h-4" />
|
||||||
|
<span>提交</span>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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<PostDto[]>(() => {
|
||||||
|
return queryData?.pages?.flatMap((page: any) => page.items) || [];
|
||||||
|
}, [queryData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingCard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-center py-12 text-slate-500">
|
||||||
|
暂无回复,来发表第一条回复吧
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mt-6">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{items.map((comment, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={comment.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.2,
|
||||||
|
delay: index * 0.05,
|
||||||
|
}}>
|
||||||
|
<PostCommentCard post={comment} index={index} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 加载更多触发器 */}
|
||||||
|
<div ref={loadMoreRef} className="h-20">
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<div className="w-6 h-6 border-2 border-[#00308F] border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.length > 0 &&
|
||||||
|
!hasNextPage && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="flex flex-col items-center py-4 space-y-2">
|
||||||
|
<div className="h-px w-16 bg-gradient-to-r from-transparent via-[#00308F]/30 to-transparent" />
|
||||||
|
<span className="text-sm text-gray-500 font-medium">
|
||||||
|
已加载全部评论
|
||||||
|
</span>
|
||||||
|
<motion.div
|
||||||
|
className="h-px w-16 bg-gradient-to-r from-transparent via-[#00308F]/30 to-transparent"
|
||||||
|
initial={{ width: "4rem" }}
|
||||||
|
animate={{ width: "12rem" }}
|
||||||
|
transition={{
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
repeatType: "reverse",
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,7 +1,11 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
import { PostDetailProvider } from "./context/PostDetailContext";
|
import { PostDetailProvider } from "./context/PostDetailContext";
|
||||||
import PostDetailLayout from "./layout/PostDetailLayout";
|
import PostDetailLayout from "./layout/PostDetailLayout";
|
||||||
|
|
||||||
export default function PostDetail({ id }: { id?: string }) {
|
export default function PostDetail({ id }: { id?: string }) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PostDetailProvider editId={id}>
|
<PostDetailProvider editId={id}>
|
||||||
|
|
|
@ -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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="relative bg-gradient-to-br from-[#E6E9F0] via-[#EDF0F8] to-[#D8E2EF] rounded-lg p-6 shadow-lg border border-[#97A9C4]/30">
|
||||||
|
{/* Corner Decorations */}
|
||||||
|
<div className="absolute top-0 left-0 w-5 h-5 border-t-2 border-l-2 border-[#97A9C4] rounded-tl-lg" />
|
||||||
|
<div className="absolute bottom-0 right-0 w-5 h-5 border-b-2 border-r-2 border-[#97A9C4] rounded-br-lg" />
|
||||||
|
|
||||||
|
{/* Title Section */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="relative mb-6">
|
||||||
|
<div className="absolute -left-2 top-1/2 -translate-y-1/2 w-1 h-8 bg-[#97A9C4]" />
|
||||||
|
<h1 className="text-2xl font-bold text-[#2B4C7E] pl-4 tracking-wider uppercase">
|
||||||
|
{post?.title}
|
||||||
|
</h1>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* First Row - Basic Info */}
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{/* Author Info Badge */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
// transition={{ delay: 0.3 }}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-md border border-[#97A9C4]/50 shadow-md hover:bg-[#F8FAFC] transition-colors duration-300">
|
||||||
|
<UserCircleIcon className="h-5 w-5 text-[#2B4C7E]" />
|
||||||
|
<span className="font-medium text-[#2B4C7E]">
|
||||||
|
{post?.author?.showname || "匿名用户"}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Date Info Badge */}
|
||||||
|
{post?.createdAt && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
// transition={{ delay: 0.4 }}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-md border border-[#97A9C4]/50 shadow-md hover:bg-[#F8FAFC] transition-colors duration-300">
|
||||||
|
<CalendarIcon className="h-5 w-5 text-[#2B4C7E]" />
|
||||||
|
<span className="text-[#2B4C7E]">
|
||||||
|
{format(
|
||||||
|
new Date(post?.createdAt),
|
||||||
|
"yyyy.MM.dd"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Last Updated Badge */}
|
||||||
|
{post?.updatedAt && post.updatedAt !== post.createdAt && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
// transition={{ delay: 0.45 }}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-md border border-[#97A9C4]/50 shadow-md hover:bg-[#F8FAFC] transition-colors duration-300">
|
||||||
|
<ClockIcon className="h-5 w-5 text-[#2B4C7E]" />
|
||||||
|
<span className="text-[#2B4C7E]">
|
||||||
|
更新于:{" "}
|
||||||
|
{format(
|
||||||
|
new Date(post?.updatedAt),
|
||||||
|
"yyyy.MM.dd"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Visibility Status Badge */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
// transition={{ delay: 0.5 }}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-md border border-[#97A9C4]/50 shadow-md hover:bg-[#F8FAFC] transition-colors duration-300">
|
||||||
|
{post?.isPublic ? (
|
||||||
|
<LockOpenIcon className="h-5 w-5 text-[#2B4C7E]" />
|
||||||
|
) : (
|
||||||
|
<LockClosedIcon className="h-5 w-5 text-[#2B4C7E]" />
|
||||||
|
)}
|
||||||
|
<span className="text-[#2B4C7E]">
|
||||||
|
{post?.isPublic ? "公开" : "私信"}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Second Row - Term and Tags */}
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{/* Term Badge */}
|
||||||
|
{post?.term?.name && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.55 }}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
className="flex items-center gap-2 bg-[#507AAF]/10 px-3 py-1.5 rounded border border-[#97A9C4]/50 shadow-md hover:bg-[#507AAF]/20">
|
||||||
|
<StarIcon className="h-5 w-5 text-[#2B4C7E]" />
|
||||||
|
<span className="font-medium text-[#2B4C7E]">
|
||||||
|
{post.term.name}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags Badges */}
|
||||||
|
{post?.meta?.tags && post.meta.tags.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.6 }}
|
||||||
|
className="flex flex-wrap gap-2">
|
||||||
|
{post.meta.tags.map((tag, index) => (
|
||||||
|
<motion.span
|
||||||
|
key={index}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
className="inline-flex items-center bg-[#507AAF]/10 px-3 py-1.5 rounded border border-[#97A9C4]/50 shadow-md hover:bg-[#507AAF]/20">
|
||||||
|
<span className="text-sm text-[#2B4C7E]">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
</motion.span>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Section */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.6 }}
|
||||||
|
className="mt-6 text-[#2B4C7E]">
|
||||||
|
<div
|
||||||
|
className="ql-editor space-y-4 leading-relaxed bg-white/60 p-4 rounded-md border border-[#97A9C4]/30 shadow-inner hover:bg-white/80 transition-colors duration-300"
|
||||||
|
dangerouslySetInnerHTML={{ __html: post?.content || "" }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Stats Section */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.7 }}
|
||||||
|
className="mt-6 flex flex-wrap gap-4 justify-start items-center">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={likeThisPost}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-md ${
|
||||||
|
post?.liked
|
||||||
|
? "bg-[#507AAF] text-white"
|
||||||
|
: "bg-white text-[#2B4C7E] hover:bg-[#507AAF] hover:text-white"
|
||||||
|
} transition-all duration-300 shadow-md border border-[#97A9C4]/30`}>
|
||||||
|
<StarIcon
|
||||||
|
className={`h-5 w-5 ${post?.liked ? "fill-white" : ""}`}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">
|
||||||
|
{post?.likes || 0} 有帮助
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white text-[#2B4C7E] rounded-md shadow-md border border-[#97A9C4]/30">
|
||||||
|
<EyeIcon className="h-5 w-5" />
|
||||||
|
<span className="font-medium">{post?.views || 0} 浏览</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white text-[#2B4C7E] rounded-md shadow-md border border-[#97A9C4]/30">
|
||||||
|
<ChatBubbleLeftIcon className="h-5 w-5" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{post?.commentsCount || 0} 评论
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,12 +1,13 @@
|
||||||
import { api, usePost } from "@nice/client";
|
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 React, { createContext, ReactNode, useState } from "react";
|
||||||
import { string } from "zod";
|
|
||||||
|
|
||||||
interface PostDetailContextType {
|
interface PostDetailContextType {
|
||||||
editId?: string; // 添加 editId
|
editId?: string; // 添加 editId
|
||||||
post?: Post;
|
post?: PostDto;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
user?: UserProfile;
|
||||||
}
|
}
|
||||||
interface PostFormProviderProps {
|
interface PostFormProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
@ -19,11 +20,13 @@ export function PostDetailProvider({
|
||||||
children,
|
children,
|
||||||
editId,
|
editId,
|
||||||
}: PostFormProviderProps) {
|
}: 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
|
api.post.findFirst as any
|
||||||
).useQuery(
|
).useQuery(
|
||||||
{
|
{
|
||||||
where: { id: editId },
|
where: { id: editId },
|
||||||
|
select: postDetailSelect,
|
||||||
},
|
},
|
||||||
{ enabled: Boolean(editId) }
|
{ enabled: Boolean(editId) }
|
||||||
);
|
);
|
||||||
|
@ -36,7 +39,7 @@ export function PostDetailProvider({
|
||||||
value={{
|
value={{
|
||||||
editId,
|
editId,
|
||||||
post,
|
post,
|
||||||
|
user,
|
||||||
isLoading,
|
isLoading,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -1,12 +1,34 @@
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useContext } from "react";
|
import { useContext, useEffect } from "react";
|
||||||
import { PostDetailContext } from "../context/PostDetailContext";
|
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() {
|
export default function PostDetailLayout() {
|
||||||
const { post } = useContext(PostDetailContext);
|
const { post, user } = useContext(PostDetailContext);
|
||||||
|
const { read } = useVisitor();
|
||||||
|
|
||||||
return <div>
|
useEffect(() => {
|
||||||
|
if (post) {
|
||||||
|
console.log("read");
|
||||||
</div>;
|
read.mutateAsync({
|
||||||
|
data: {
|
||||||
|
visitorId: user?.id || null,
|
||||||
|
postId: post.id,
|
||||||
|
type: VisitType.READED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [post]);
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 flex flex-col">
|
||||||
|
<PostHeader></PostHeader>
|
||||||
|
<PostCommentEditor></PostCommentEditor>
|
||||||
|
<PostCommentList></PostCommentList>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
export const isContentEmpty = (html: string) => {
|
||||||
|
// 创建一个临时 div 来解析 HTML 内容
|
||||||
|
const temp = document.createElement("div");
|
||||||
|
temp.innerHTML = html;
|
||||||
|
// 获取纯文本内容并检查是否为空
|
||||||
|
return !temp.textContent?.trim();
|
||||||
|
};
|
|
@ -6,13 +6,13 @@
|
||||||
border-top-left-radius: 8px;
|
border-top-left-radius: 8px;
|
||||||
border-top-right-radius: 8px;
|
border-top-right-radius: 8px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
border: none
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quill-editor-container .ql-container {
|
.quill-editor-container .ql-container {
|
||||||
border-bottom-left-radius: 8px;
|
border-bottom-left-radius: 8px;
|
||||||
border-bottom-right-radius: 8px;
|
border-bottom-right-radius: 8px;
|
||||||
border: none
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ag-custom-dragging-class {
|
.ag-custom-dragging-class {
|
||||||
|
@ -45,11 +45,11 @@
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-thead>tr>th {
|
.ant-table-thead > tr > th {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-tbody>tr>td {
|
.ant-table-tbody > tr > td {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
border-bottom-color: transparent !important;
|
border-bottom-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
@ -85,7 +85,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 覆盖 Ant Design 的默认样式 raido button左侧的按钮*/
|
/* 覆盖 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;
|
background-color: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,7 +100,7 @@
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-wrap-header .ant-table-thead>tr>th {
|
.no-wrap-header .ant-table-thead > tr > th {
|
||||||
white-space: nowrap;
|
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;
|
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;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { api } from "../trpc";
|
import { api } from "../trpc";
|
||||||
import { TroubleParams } from "../../singleton/DataHolder";
|
import { PostParams } from "../../singleton/DataHolder";
|
||||||
|
|
||||||
export function useVisitor() {
|
export function useVisitor() {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const troubleParams = TroubleParams.getInstance();
|
const postParams = PostParams.getInstance();
|
||||||
|
|
||||||
const create = api.visitor.create.useMutation({
|
const create = api.visitor.create.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
utils.visitor.invalidate();
|
utils.visitor.invalidate();
|
||||||
// utils.trouble.invalidate();
|
// utils.post.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
/**
|
/**
|
||||||
|
@ -19,68 +19,71 @@ export function useVisitor() {
|
||||||
const createOptimisticMutation = (
|
const createOptimisticMutation = (
|
||||||
updateFn: (item: any, variables: any) => any
|
updateFn: (item: any, variables: any) => any
|
||||||
) => ({
|
) => ({
|
||||||
// 在请求发送前执行本地数据预更新
|
//在请求发送前执行本地数据预更新
|
||||||
// onMutate: async (variables: any) => {
|
onMutate: async (variables: any) => {
|
||||||
// const previousDataList: any[] = [];
|
const previousDataList: any[] = [];
|
||||||
// // 动态生成参数列表,包括星标和其他参数
|
// 动态生成参数列表,包括星标和其他参数
|
||||||
|
|
||||||
// const paramsList = troubleParams.getItems();
|
const paramsList = postParams.getItems();
|
||||||
// console.log(paramsList.length);
|
console.log("paramsList.length", paramsList.length);
|
||||||
// // 遍历所有参数列表,执行乐观更新
|
// 遍历所有参数列表,执行乐观更新
|
||||||
// for (const params of paramsList) {
|
for (const params of paramsList) {
|
||||||
// // 取消可能的并发请求
|
// 取消可能的并发请求
|
||||||
// await utils.trouble.findManyWithCursor.cancel();
|
await utils.post.findManyWithCursor.cancel();
|
||||||
// // 获取并保存当前数据
|
// 获取并保存当前数据
|
||||||
// const previousData =
|
const previousData =
|
||||||
// utils.trouble.findManyWithCursor.getInfiniteData({
|
utils.post.findManyWithCursor.getInfiniteData({
|
||||||
// ...params,
|
...params,
|
||||||
// });
|
});
|
||||||
// previousDataList.push(previousData);
|
previousDataList.push(previousData);
|
||||||
// // 执行乐观更新
|
// 执行乐观更新
|
||||||
// utils.trouble.findManyWithCursor.setInfiniteData(
|
utils.post.findManyWithCursor.setInfiniteData(
|
||||||
// {
|
{
|
||||||
// ...params,
|
...params,
|
||||||
// },
|
},
|
||||||
// (oldData) => {
|
(oldData) => {
|
||||||
// if (!oldData) return oldData;
|
if (!oldData) return oldData;
|
||||||
// return {
|
return {
|
||||||
// ...oldData,
|
...oldData,
|
||||||
// pages: oldData.pages.map((page) => ({
|
pages: oldData.pages.map((page) => ({
|
||||||
// ...page,
|
...page,
|
||||||
// items: page.items.map((item) =>
|
items: (page.items as any).map((item) =>
|
||||||
// item.id === variables?.troubleId
|
item.id === variables?.postId
|
||||||
// ? updateFn(item, variables)
|
? updateFn(item, variables)
|
||||||
// : item
|
: item
|
||||||
// ),
|
),
|
||||||
// })),
|
})),
|
||||||
// };
|
};
|
||||||
// }
|
}
|
||||||
// );
|
);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// return { previousDataList };
|
return { previousDataList };
|
||||||
// },
|
},
|
||||||
// // 错误处理:数据回滚
|
// 错误处理:数据回滚
|
||||||
// onError: (_err: any, _variables: any, context: any) => {
|
onError: (_err: any, _variables: any, context: any) => {
|
||||||
// const paramsList = troubleParams.getItems();
|
const paramsList = postParams.getItems();
|
||||||
// paramsList.forEach((params, index) => {
|
paramsList.forEach((params, index) => {
|
||||||
// if (context?.previousDataList?.[index]) {
|
if (context?.previousDataList?.[index]) {
|
||||||
// utils.trouble.findManyWithCursor.setInfiniteData(
|
utils.post.findManyWithCursor.setInfiniteData(
|
||||||
// { ...params },
|
{ ...params },
|
||||||
// context.previousDataList[index]
|
context.previousDataList[index]
|
||||||
// );
|
);
|
||||||
// }
|
}
|
||||||
// });
|
});
|
||||||
// },
|
},
|
||||||
// // 成功后的缓存失效
|
// 成功后的缓存失效
|
||||||
// onSuccess: (_: any, variables: any) => {
|
onSuccess: async (_: any, variables: any) => {
|
||||||
// utils.visitor.invalidate();
|
await Promise.all([
|
||||||
// utils.trouble.findFirst.invalidate({
|
utils.visitor.invalidate(),
|
||||||
// where: {
|
utils.post.findFirst.invalidate({
|
||||||
// id: (variables as any)?.troubleId,
|
where: {
|
||||||
// },
|
id: (variables as any)?.postId,
|
||||||
// });
|
},
|
||||||
// },
|
}),
|
||||||
|
utils.post.findManyWithCursor.invalidate(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
// 定义具体的mutation
|
// 定义具体的mutation
|
||||||
const read = api.visitor.create.useMutation(
|
const read = api.visitor.create.useMutation(
|
||||||
|
@ -90,6 +93,13 @@ export function useVisitor() {
|
||||||
readed: true,
|
readed: true,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
const like = api.visitor.create.useMutation(
|
||||||
|
createOptimisticMutation((item) => ({
|
||||||
|
...item,
|
||||||
|
likes: (item.likes || 0) + 1,
|
||||||
|
liked: true,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const addStar = api.visitor.create.useMutation(
|
const addStar = api.visitor.create.useMutation(
|
||||||
createOptimisticMutation((item) => ({
|
createOptimisticMutation((item) => ({
|
||||||
|
@ -120,12 +130,13 @@ export function useVisitor() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
troubleParams,
|
postParams,
|
||||||
create,
|
create,
|
||||||
createMany,
|
createMany,
|
||||||
deleteMany,
|
deleteMany,
|
||||||
read,
|
read,
|
||||||
addStar,
|
addStar,
|
||||||
deleteStar,
|
deleteStar,
|
||||||
|
like,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,36 +1,53 @@
|
||||||
export class TroubleParams {
|
export class PostParams {
|
||||||
private static instance: TroubleParams; // 静态私有变量,用于存储单例实例
|
private static instance: PostParams; // 静态私有变量,用于存储单例实例
|
||||||
private troubleParams: Array<object>; // 私有数组属性,用于存储对象
|
private postParams: Array<object>; // 私有数组属性,用于存储对象
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.troubleParams = []; // 初始化空数组
|
this.postParams = []; // 初始化空数组
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getInstance(): TroubleParams {
|
public static getInstance(): PostParams {
|
||||||
if (!TroubleParams.instance) {
|
if (!PostParams.instance) {
|
||||||
TroubleParams.instance = new TroubleParams();
|
PostParams.instance = new PostParams();
|
||||||
}
|
}
|
||||||
return TroubleParams.instance;
|
return PostParams.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public addItem(item: object): void {
|
public addItem(item: object): void {
|
||||||
// 代码意图解析: 向数组中添加一个对象,确保不会添加重复的对象。
|
// 使用更可靠的方式比较查询参数
|
||||||
// 技术原理阐述: 在添加对象之前,使用 `some` 方法检查数组中是否已经存在相同的对象。如果不存在,则添加到数组中。
|
const isDuplicate = this.postParams.some((existingItem: any) => {
|
||||||
// 数据结构解读: `some` 方法遍历数组,检查是否存在满足条件的元素。`JSON.stringify` 用于将对象转换为字符串进行比较。
|
if (item && existingItem) {
|
||||||
// 算法复杂度分析: `some` 方法的复杂度为 O(n),因为需要遍历数组中的每个元素。`JSON.stringify` 的复杂度取决于对象的大小,通常为 O(m),其中 m 是对象的属性数量。因此,总复杂度为 O(n * m)。
|
const itemWhere = (item as any).where;
|
||||||
// 可能的优化建议: 如果数组非常大,可以考虑使用哈希表(如 `Map` 或 `Set`)来存储对象的唯一标识符,以提高查找效率。
|
const existingWhere = existingItem.where;
|
||||||
|
return (
|
||||||
const isDuplicate = this.troubleParams.some(
|
itemWhere?.parentId === existingWhere?.parentId &&
|
||||||
(existingItem) =>
|
itemWhere?.type === existingWhere?.type
|
||||||
JSON.stringify(existingItem) === JSON.stringify(item)
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
if (!isDuplicate) {
|
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<object> {
|
public getItems(): Array<object> {
|
||||||
return [...this.troubleParams]; // 返回数组的副本,防止外部直接修改原数组
|
return [...this.postParams];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -187,6 +187,7 @@ model Post {
|
||||||
// 字符串类型字段
|
// 字符串类型字段
|
||||||
id String @id @default(cuid()) // 帖子唯一标识,使用 cuid() 生成默认值
|
id String @id @default(cuid()) // 帖子唯一标识,使用 cuid() 生成默认值
|
||||||
type String? // 帖子类型,可为空
|
type String? // 帖子类型,可为空
|
||||||
|
state String? // 状态 : 未读、处理中、已回答
|
||||||
title String? // 帖子标题,可为空
|
title String? // 帖子标题,可为空
|
||||||
content String? // 帖子内容,可为空
|
content String? // 帖子内容,可为空
|
||||||
domainId String? @map("domain_id")
|
domainId String? @map("domain_id")
|
||||||
|
@ -194,12 +195,15 @@ model Post {
|
||||||
termId String? @map("term_id")
|
termId String? @map("term_id")
|
||||||
// 日期时间类型字段
|
// 日期时间类型字段
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @map("updated_at")
|
||||||
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
|
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
|
||||||
// 关系类型字段
|
// 关系类型字段
|
||||||
authorId String? @map("author_id")
|
authorId String? @map("author_id")
|
||||||
author Staff? @relation("post_author", fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型
|
author Staff? @relation("post_author", fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型
|
||||||
visits Visit[] // 访问记录,关联 Visit 模型
|
visits Visit[] // 访问记录,关联 Visit 模型
|
||||||
|
views Int @default(0)
|
||||||
|
likes Int @default(0)
|
||||||
|
|
||||||
receivers Staff[] @relation("post_receiver")
|
receivers Staff[] @relation("post_receiver")
|
||||||
parentId String? @map("parent_id")
|
parentId String? @map("parent_id")
|
||||||
parent Post? @relation("PostChildren", fields: [parentId], references: [id]) // 父级帖子,关联 Post 模型
|
parent Post? @relation("PostChildren", fields: [parentId], references: [id]) // 父级帖子,关联 Post 模型
|
||||||
|
@ -242,8 +246,8 @@ model Visit {
|
||||||
views Int @default(1) @map("views")
|
views Int @default(1) @map("views")
|
||||||
// sourceIP String? @map("source_ip")
|
// sourceIP String? @map("source_ip")
|
||||||
// 关联关系
|
// 关联关系
|
||||||
visitorId String @map("visitor_id")
|
visitorId String? @map("visitor_id")
|
||||||
visitor Staff @relation(fields: [visitorId], references: [id])
|
visitor Staff? @relation(fields: [visitorId], references: [id])
|
||||||
postId String? @map("post_id")
|
postId String? @map("post_id")
|
||||||
post Post? @relation(fields: [postId], references: [id])
|
post Post? @relation(fields: [postId], references: [id])
|
||||||
message Message? @relation(fields: [messageId], references: [id])
|
message Message? @relation(fields: [messageId], references: [id])
|
||||||
|
|
|
@ -4,7 +4,7 @@ export enum SocketMsgType {
|
||||||
export enum PostType {
|
export enum PostType {
|
||||||
POST = "post",
|
POST = "post",
|
||||||
POST_COMMENT = "post_comment",
|
POST_COMMENT = "post_comment",
|
||||||
COURSE_REVIEW = "course_review"
|
COURSE_REVIEW = "course_review",
|
||||||
}
|
}
|
||||||
export enum TaxonomySlug {
|
export enum TaxonomySlug {
|
||||||
CATEGORY = "category",
|
CATEGORY = "category",
|
||||||
|
@ -13,24 +13,24 @@ export enum TaxonomySlug {
|
||||||
export enum VisitType {
|
export enum VisitType {
|
||||||
STAR = "star",
|
STAR = "star",
|
||||||
READED = "read",
|
READED = "read",
|
||||||
|
LIKE = "like",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export enum StorageProvider {
|
export enum StorageProvider {
|
||||||
LOCAL = 'LOCAL',
|
LOCAL = "LOCAL",
|
||||||
S3 = 'S3',
|
S3 = "S3",
|
||||||
OSS = 'OSS',
|
OSS = "OSS",
|
||||||
COS = 'COS',
|
COS = "COS",
|
||||||
CDN = 'CDN'
|
CDN = "CDN",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ResourceStatus {
|
export enum ResourceStatus {
|
||||||
UPLOADING = "UPLOADING",
|
UPLOADING = "UPLOADING",
|
||||||
UPLOADED = "UPLOADED",
|
UPLOADED = "UPLOADED",
|
||||||
PROCESS_PENDING = 'PROCESS_PENDING',
|
PROCESS_PENDING = "PROCESS_PENDING",
|
||||||
PROCESSING = 'PROCESSING',
|
PROCESSING = "PROCESSING",
|
||||||
PROCESSED = 'PROCESSED',
|
PROCESSED = "PROCESSED",
|
||||||
PROCESS_FAILED = 'PROCESS_FAILED'
|
PROCESS_FAILED = "PROCESS_FAILED",
|
||||||
}
|
}
|
||||||
export enum ObjectType {
|
export enum ObjectType {
|
||||||
DEPARTMENT = "department",
|
DEPARTMENT = "department",
|
||||||
|
@ -47,7 +47,7 @@ export enum ObjectType {
|
||||||
SECTION = "section",
|
SECTION = "section",
|
||||||
LECTURE = "lecture",
|
LECTURE = "lecture",
|
||||||
ENROLLMENT = "enrollment",
|
ENROLLMENT = "enrollment",
|
||||||
RESOURCE = "resource"
|
RESOURCE = "resource",
|
||||||
}
|
}
|
||||||
export enum RolePerms {
|
export enum RolePerms {
|
||||||
// Create Permissions 创建权限
|
// Create Permissions 创建权限
|
||||||
|
@ -103,14 +103,14 @@ export enum ResourceType {
|
||||||
IMAGE = "image", // 图片资源
|
IMAGE = "image", // 图片资源
|
||||||
AUDIO = "audio", // 音频资源
|
AUDIO = "audio", // 音频资源
|
||||||
ZIP = "zip", // 压缩包文件
|
ZIP = "zip", // 压缩包文件
|
||||||
OTHER = "other" // 其他未分类资源
|
OTHER = "other", // 其他未分类资源
|
||||||
}
|
}
|
||||||
// 课程等级的枚举,描述了不同学习水平的课程
|
// 课程等级的枚举,描述了不同学习水平的课程
|
||||||
export enum CourseLevel {
|
export enum CourseLevel {
|
||||||
BEGINNER = "beginner", // 初级课程,适合初学者
|
BEGINNER = "beginner", // 初级课程,适合初学者
|
||||||
INTERMEDIATE = "intermediate", // 中级课程,适合有一定基础的学习者
|
INTERMEDIATE = "intermediate", // 中级课程,适合有一定基础的学习者
|
||||||
ADVANCED = "advanced", // 高级课程,适合高级水平学习者
|
ADVANCED = "advanced", // 高级课程,适合高级水平学习者
|
||||||
ALL_LEVELS = "all_levels" // 适用于所有学习水平的课程
|
ALL_LEVELS = "all_levels", // 适用于所有学习水平的课程
|
||||||
}
|
}
|
||||||
|
|
||||||
// 课时(课程内容)类型的枚举,定义了课程中可能包含的不同内容形式
|
// 课时(课程内容)类型的枚举,定义了课程中可能包含的不同内容形式
|
||||||
|
@ -126,13 +126,13 @@ export enum CourseStatus {
|
||||||
DRAFT = "draft", // 草稿状态的课程,尚未发布
|
DRAFT = "draft", // 草稿状态的课程,尚未发布
|
||||||
UNDER_REVIEW = "under_review", // 正在审核中的课程
|
UNDER_REVIEW = "under_review", // 正在审核中的课程
|
||||||
PUBLISHED = "published", // 已发布的课程,可以被学员报名学习
|
PUBLISHED = "published", // 已发布的课程,可以被学员报名学习
|
||||||
ARCHIVED = "archived" // 已归档的课程,不再对外展示
|
ARCHIVED = "archived", // 已归档的课程,不再对外展示
|
||||||
}
|
}
|
||||||
export const CourseStatusLabel: Record<CourseStatus, string> = {
|
export const CourseStatusLabel: Record<CourseStatus, string> = {
|
||||||
[CourseStatus.DRAFT]: "草稿",
|
[CourseStatus.DRAFT]: "草稿",
|
||||||
[CourseStatus.UNDER_REVIEW]: "审核中",
|
[CourseStatus.UNDER_REVIEW]: "审核中",
|
||||||
[CourseStatus.PUBLISHED]: "已发布",
|
[CourseStatus.PUBLISHED]: "已发布",
|
||||||
[CourseStatus.ARCHIVED]: "已归档"
|
[CourseStatus.ARCHIVED]: "已归档",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 报名状态的枚举,描述了用户报名参加课程的不同状态
|
// 报名状态的枚举,描述了用户报名参加课程的不同状态
|
||||||
|
@ -141,52 +141,51 @@ export enum EnrollmentStatus {
|
||||||
ACTIVE = "active", // 活跃状态,用户可参与课程
|
ACTIVE = "active", // 活跃状态,用户可参与课程
|
||||||
COMPLETED = "completed", // 完成状态,用户已完成课程
|
COMPLETED = "completed", // 完成状态,用户已完成课程
|
||||||
CANCELLED = "cancelled", // 已取消的报名
|
CANCELLED = "cancelled", // 已取消的报名
|
||||||
REFUNDED = "refunded" // 已退款的报名
|
REFUNDED = "refunded", // 已退款的报名
|
||||||
}
|
}
|
||||||
|
|
||||||
// 授课角色的枚举,定义了讲师在课程中的角色分配
|
// 授课角色的枚举,定义了讲师在课程中的角色分配
|
||||||
export enum InstructorRole {
|
export enum InstructorRole {
|
||||||
MAIN = "main", // 主讲教师
|
MAIN = "main", // 主讲教师
|
||||||
ASSISTANT = "assistant" // 助教
|
ASSISTANT = "assistant", // 助教
|
||||||
}
|
}
|
||||||
export const EnrollmentStatusLabel = {
|
export const EnrollmentStatusLabel = {
|
||||||
[EnrollmentStatus.PENDING]: '待处理',
|
[EnrollmentStatus.PENDING]: "待处理",
|
||||||
[EnrollmentStatus.ACTIVE]: '进行中',
|
[EnrollmentStatus.ACTIVE]: "进行中",
|
||||||
[EnrollmentStatus.COMPLETED]: '已完成',
|
[EnrollmentStatus.COMPLETED]: "已完成",
|
||||||
[EnrollmentStatus.CANCELLED]: '已取消',
|
[EnrollmentStatus.CANCELLED]: "已取消",
|
||||||
[EnrollmentStatus.REFUNDED]: '已退款'
|
[EnrollmentStatus.REFUNDED]: "已退款",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InstructorRoleLabel = {
|
export const InstructorRoleLabel = {
|
||||||
[InstructorRole.MAIN]: '主讲教师',
|
[InstructorRole.MAIN]: "主讲教师",
|
||||||
[InstructorRole.ASSISTANT]: '助教'
|
[InstructorRole.ASSISTANT]: "助教",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ResourceTypeLabel = {
|
export const ResourceTypeLabel = {
|
||||||
[ResourceType.VIDEO]: '视频',
|
[ResourceType.VIDEO]: "视频",
|
||||||
[ResourceType.PDF]: 'PDF文档',
|
[ResourceType.PDF]: "PDF文档",
|
||||||
[ResourceType.DOC]: 'Word文档',
|
[ResourceType.DOC]: "Word文档",
|
||||||
[ResourceType.EXCEL]: 'Excel表格',
|
[ResourceType.EXCEL]: "Excel表格",
|
||||||
[ResourceType.PPT]: 'PPT演示文稿',
|
[ResourceType.PPT]: "PPT演示文稿",
|
||||||
[ResourceType.CODE]: '代码文件',
|
[ResourceType.CODE]: "代码文件",
|
||||||
[ResourceType.LINK]: '链接',
|
[ResourceType.LINK]: "链接",
|
||||||
[ResourceType.IMAGE]: '图片',
|
[ResourceType.IMAGE]: "图片",
|
||||||
[ResourceType.AUDIO]: '音频',
|
[ResourceType.AUDIO]: "音频",
|
||||||
[ResourceType.ZIP]: '压缩包',
|
[ResourceType.ZIP]: "压缩包",
|
||||||
[ResourceType.OTHER]: '其他'
|
[ResourceType.OTHER]: "其他",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CourseLevelLabel = {
|
export const CourseLevelLabel = {
|
||||||
[CourseLevel.BEGINNER]: '初级',
|
[CourseLevel.BEGINNER]: "初级",
|
||||||
[CourseLevel.INTERMEDIATE]: '中级',
|
[CourseLevel.INTERMEDIATE]: "中级",
|
||||||
[CourseLevel.ADVANCED]: '高级',
|
[CourseLevel.ADVANCED]: "高级",
|
||||||
[CourseLevel.ALL_LEVELS]: '不限级别'
|
[CourseLevel.ALL_LEVELS]: "不限级别",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LessonTypeLabel = {
|
export const LessonTypeLabel = {
|
||||||
[LessonType.VIDEO]: '视频课程',
|
[LessonType.VIDEO]: "视频课程",
|
||||||
[LessonType.ARTICLE]: '图文课程',
|
[LessonType.ARTICLE]: "图文课程",
|
||||||
[LessonType.QUIZ]: '测验',
|
[LessonType.QUIZ]: "测验",
|
||||||
[LessonType.ASSIGNMENT]: '作业'
|
[LessonType.ASSIGNMENT]: "作业",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,2 +1,46 @@
|
||||||
import { Prisma } from "@prisma/client";
|
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,
|
||||||
|
};
|
||||||
|
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
Message,
|
Message,
|
||||||
Post,
|
Post,
|
||||||
RoleMap,
|
RoleMap,
|
||||||
|
Resource,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
import { SocketMsgType, RolePerms } from "./enum";
|
import { SocketMsgType, RolePerms } from "./enum";
|
||||||
import { RowRequestSchema } from "./schema";
|
import { RowRequestSchema } from "./schema";
|
||||||
|
@ -125,16 +126,20 @@ export type PostComment = {
|
||||||
};
|
};
|
||||||
export type PostDto = Post & {
|
export type PostDto = Post & {
|
||||||
readed: boolean;
|
readed: boolean;
|
||||||
|
liked: boolean;
|
||||||
readedCount: number;
|
readedCount: number;
|
||||||
author: StaffDto;
|
|
||||||
limitedComments: PostComment[];
|
|
||||||
commentsCount: number;
|
commentsCount: number;
|
||||||
|
term: TermDto;
|
||||||
|
author: StaffDto | undefined;
|
||||||
|
receivers: StaffDto[];
|
||||||
|
resources: Resource[];
|
||||||
perms?: {
|
perms?: {
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
// edit: boolean;
|
// edit: boolean;
|
||||||
};
|
};
|
||||||
watchableDepts: Department[];
|
|
||||||
watchableStaffs: Staff[];
|
views: number;
|
||||||
|
meta?: PostMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TermDto = Term & {
|
export type TermDto = Term & {
|
||||||
|
@ -161,6 +166,7 @@ export interface BaseSetting {
|
||||||
export interface PostMeta {
|
export interface PostMeta {
|
||||||
signature?: string;
|
signature?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
export type RowModelResult = {
|
export type RowModelResult = {
|
||||||
rowData: any[];
|
rowData: any[];
|
||||||
|
|
|
@ -12,7 +12,7 @@ importers:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/bullmq':
|
'@nestjs/bullmq':
|
||||||
specifier: ^10.2.0
|
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':
|
'@nestjs/common':
|
||||||
specifier: ^10.3.10
|
specifier: ^10.3.10
|
||||||
version: 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
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)
|
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':
|
'@nestjs/schedule':
|
||||||
specifier: ^4.1.0
|
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':
|
'@nestjs/websockets':
|
||||||
specifier: ^10.3.10
|
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)
|
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)
|
version: 10.2.3(chokidar@3.6.0)(typescript@5.7.2)
|
||||||
'@nestjs/testing':
|
'@nestjs/testing':
|
||||||
specifier: ^10.0.0
|
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':
|
'@types/exceljs':
|
||||||
specifier: ^1.3.0
|
specifier: ^1.3.0
|
||||||
version: 1.3.2
|
version: 1.3.2
|
||||||
|
@ -389,6 +389,9 @@ importers:
|
||||||
react-hot-toast:
|
react-hot-toast:
|
||||||
specifier: ^2.4.1
|
specifier: ^2.4.1
|
||||||
version: 2.5.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
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:
|
react-resizable:
|
||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
version: 3.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
version: 3.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
@ -6023,6 +6026,15 @@ packages:
|
||||||
react: '>=16.8.1'
|
react: '>=16.8.1'
|
||||||
react-dom: '>=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:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
|
@ -8623,15 +8635,15 @@ snapshots:
|
||||||
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
'@nestjs/common': 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)
|
'@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
|
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:
|
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/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/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
|
bullmq: 5.34.8
|
||||||
|
@ -8728,7 +8740,7 @@ snapshots:
|
||||||
- supports-color
|
- supports-color
|
||||||
- utf-8-validate
|
- 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:
|
dependencies:
|
||||||
'@nestjs/common': 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)
|
'@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)
|
||||||
|
@ -8746,7 +8758,7 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- chokidar
|
- 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:
|
dependencies:
|
||||||
'@nestjs/common': 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)
|
'@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)
|
||||||
|
@ -13380,6 +13392,12 @@ snapshots:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
react-dom: 18.2.0(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@16.13.1: {}
|
||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|
Loading…
Reference in New Issue