add 001749

This commit is contained in:
ditiqi 2025-01-24 00:19:02 +08:00
parent 7df1e997c1
commit 199d025cd9
39 changed files with 1603 additions and 422 deletions

View File

@ -27,8 +27,10 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
) {
args.data.authorId = params?.staff?.id;
args.data.updatedAt = new Date();
// args.data.resources
const result = await super.create(args);
await this.updateParentTimestamp(result?.parentId);
EventBus.emit('dataChanged', {
type: ObjectType.POST,
operation: CrudOperation.CREATED,
@ -38,6 +40,7 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
}
async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) {
args.data.authorId = staff?.id;
args.data.updatedAt = new Date();
const result = await super.update(args);
EventBus.emit('dataChanged', {
type: ObjectType.POST,
@ -46,6 +49,17 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
});
return result;
}
async findFirst(args?: Prisma.PostFindFirstArgs, staff?: UserProfile) {
const transDto = await this.wrapResult(
super.findFirst(args),
async (result) => {
await setPostRelation({ data: result, staff });
await this.setPerms(result, staff);
return result;
},
);
return transDto;
}
async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
if (!args.where) args.where = {};
args.where.OR = await this.preFilter(args.where.OR, staff);
@ -125,4 +139,20 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
if (orCondition?.length > 0) return orCondition;
return undefined;
}
/**
*
*
* @param parentId ID
*/
async updateParentTimestamp(parentId: string | undefined) {
if (!parentId) {
return;
}
await this.update({
where: {
id: parentId,
},
data: {}, // 空对象会自动更新 updatedAt 时间戳
});
}
}

View File

@ -5,16 +5,9 @@ export async function setPostRelation(params: {
staff?: UserProfile;
}) {
const { data, staff } = params;
const limitedComments = await db.post.findMany({
where: {
parentId: data.id,
type: PostType.POST_COMMENT,
},
include: {
author: true,
},
take: 5,
});
// 在函数开始时计算一次时间
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
const clientIp = (data?.meta as any)?.ip;
const commentsCount = await db.post.count({
where: {
parentId: data.id,
@ -29,6 +22,23 @@ export async function setPostRelation(params: {
visitorId: staff?.id,
},
})) > 0;
const liked = await db.visit.count({
where: {
postId: data.id,
type: VisitType?.LIKE,
...(staff?.id
? // 如果有 staff查找对应的 visitorId
{ visitorId: staff.id }
: // 如果没有 staff查找相同 IP 且 visitorId 为 null 且 30 分钟内的记录
{
visitorId: null,
meta: { path: ['ip'], equals: clientIp },
updatedAt: {
gte: thirtyMinutesAgo,
},
}),
},
});
const readedCount = await db.visit.count({
where: {
postId: data.id,
@ -39,7 +49,8 @@ export async function setPostRelation(params: {
Object.assign(data, {
readed,
readedCount,
limitedComments,
liked,
// limitedComments,
commentsCount,
// trouble
});

View File

@ -0,0 +1,15 @@
export function getClientIp(req: any): string {
let ip =
req.ip ||
(Array.isArray(req.headers['x-forwarded-for'])
? req.headers['x-forwarded-for'][0]
: req.headers['x-forwarded-for']) ||
req.socket.remoteAddress;
// 如果是 IPv4-mapped IPv6 地址,转换为 IPv4
if (typeof ip === 'string' && ip.startsWith('::ffff:')) {
ip = ip.substring(7);
}
return ip || '';
}

View File

@ -4,34 +4,45 @@ import { Prisma } from '@nice/common';
import { VisitService } from './visit.service';
import { z, ZodType } from 'zod';
const VisitCreateArgsSchema: ZodType<Prisma.VisitCreateArgs> = z.any()
const VisitCreateManyInputSchema: ZodType<Prisma.VisitCreateManyInput> = z.any()
const VisitDeleteManyArgsSchema: ZodType<Prisma.VisitDeleteManyArgs> = z.any()
import { getClientIp } from './utils';
const VisitCreateArgsSchema: ZodType<Prisma.VisitCreateArgs> = z.any();
const VisitCreateManyInputSchema: ZodType<Prisma.VisitCreateManyInput> =
z.any();
const VisitDeleteManyArgsSchema: ZodType<Prisma.VisitDeleteManyArgs> = z.any();
@Injectable()
export class VisitRouter {
constructor(
private readonly trpc: TrpcService,
private readonly visitService: VisitService,
) { }
router = this.trpc.router({
create: this.trpc.protectProcedure
.input(VisitCreateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.visitService.create(input, staff);
}),
createMany: this.trpc.protectProcedure.input(z.array(VisitCreateManyInputSchema))
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
constructor(
private readonly trpc: TrpcService,
private readonly visitService: VisitService,
) {}
router = this.trpc.router({
create: this.trpc.protectProcedure
.input(VisitCreateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff, req } = ctx;
// 从请求中获取 IP
const ip = getClientIp(req);
const currentMeta =
typeof input.data.meta === 'object' && input.data.meta !== null
? input.data.meta
: {};
input.data.meta = {
...currentMeta,
ip: ip || '',
} as Prisma.InputJsonObject; // 明确指定类型
return await this.visitService.create(input, staff);
}),
createMany: this.trpc.protectProcedure
.input(z.array(VisitCreateManyInputSchema))
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.visitService.createMany({ data: input }, staff);
}),
deleteMany: this.trpc.procedure
.input(VisitDeleteManyArgsSchema)
.mutation(async ({ input }) => {
return await this.visitService.deleteMany(input);
}),
});
return await this.visitService.createMany({ data: input }, staff);
}),
deleteMany: this.trpc.procedure
.input(VisitDeleteManyArgsSchema)
.mutation(async ({ input }) => {
return await this.visitService.deleteMany(input);
}),
});
}

View File

@ -9,13 +9,28 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
}
async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) {
const { postId, messageId } = args.data;
const clientIp = (args.data.meta as any)?.ip;
console.log('visit create');
const visitorId = args.data.visitorId || staff?.id;
let result;
const existingVisit = await db.visit.findFirst({
where: {
type: args.data.type,
visitorId,
OR: [{ postId }, { messageId }],
OR: [
{
AND: [
{ OR: [{ postId }, { messageId }] },
{ visitorId: visitorId || null },
],
},
{
AND: [
{ OR: [{ postId }, { messageId }] },
{ visitorId: null },
{ meta: { path: ['ip'], equals: clientIp } },
],
},
],
},
});
if (!existingVisit) {
@ -28,14 +43,36 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
views: existingVisit.views + 1,
},
});
} else if (args.data.type === VisitType.LIKE) {
if (!visitorId && existingVisit) {
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
if (existingVisit.updatedAt < thirtyMinutesAgo) {
// 如果上次更新时间超过30分钟增加view计数
result = await super.update({
where: { id: existingVisit.id },
data: {
...args.data,
views: existingVisit.views + 1,
},
});
}
}
}
// if (troubleId && args.data.type === VisitType.READED) {
// EventBus.emit('updateViewCount', {
// objectType: ObjectType.TROUBLE,
// id: troubleId,
// });
// }
if (postId && args.data.type === VisitType.READED) {
EventBus.emit('updateVisitCount', {
objectType: ObjectType.POST,
id: postId,
visitType: VisitType.READED,
});
}
if (postId && args.data.type === VisitType.LIKE) {
EventBus.emit('updateVisitCount', {
objectType: ObjectType.POST,
id: postId,
visitType: VisitType.LIKE,
});
}
return result;
}
async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) {

View File

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

View File

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

View File

@ -1,28 +1,24 @@
import { InjectQueue } from "@nestjs/bullmq";
import { Injectable } from "@nestjs/common";
import EventBus from "@server/utils/event-bus";
import { Queue } from "bullmq";
import { ObjectType } from "@nice/common";
import { QueueJobType } from "../types";
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import EventBus from '@server/utils/event-bus';
import { Queue } from 'bullmq';
import { ObjectType } from '@nice/common';
import { QueueJobType } from '../types';
@Injectable()
export class PostProcessService {
constructor(
@InjectQueue('general') private generalQueue: Queue
) {
}
constructor(@InjectQueue('general') private generalQueue: Queue) {}
private generateJobId(type: ObjectType, data: any): string {
// 根据类型和相关ID生成唯一的job标识
switch (type) {
case ObjectType.ENROLLMENT:
return `stats_${type}_${data.courseId}`;
case ObjectType.LECTURE:
return `stats_${type}_${data.courseId}_${data.sectionId}`;
case ObjectType.POST:
return `stats_${type}_${data.courseId}`;
default:
return `stats_${type}_${Date.now()}`;
}
private generateJobId(type: ObjectType, data: any): string {
// 根据类型和相关ID生成唯一的job标识
switch (type) {
case ObjectType.ENROLLMENT:
return `stats_${type}_${data.courseId}`;
case ObjectType.LECTURE:
return `stats_${type}_${data.courseId}_${data.sectionId}`;
case ObjectType.POST:
return `stats_${type}_${data.courseId}`;
default:
return `stats_${type}_${Date.now()}`;
}
}
}
}

View File

@ -2,6 +2,7 @@ import { BullModule } from '@nestjs/bullmq';
import { Logger, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { join } from 'path';
import { PostQueueService } from './models/post/post.queue.service';
@Module({
imports: [
@ -25,11 +26,10 @@ import { join } from 'path';
{
name: 'file-queue', // 新增文件处理队列
processors: [join(__dirname, 'worker/file.processor.js')], // 文件处理器的路径
}
},
),
],
providers: [Logger],
exports: []
providers: [Logger, PostQueueService],
exports: [],
})
export class QueueModule { }
export class QueueModule {}

View File

@ -1,70 +1,68 @@
import { InjectQueue } from "@nestjs/bullmq";
import { Injectable } from "@nestjs/common";
import EventBus from "@server/utils/event-bus";
import { Queue } from "bullmq";
import { ObjectType } from "@nice/common";
import { QueueJobType } from "../types";
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import EventBus from '@server/utils/event-bus';
import { Queue } from 'bullmq';
import { ObjectType } from '@nice/common';
import { QueueJobType } from '../types';
@Injectable()
export class StatsService {
constructor(
@InjectQueue('general') private generalQueue: Queue
) {
EventBus.on("dataChanged", async ({ type, data }) => {
const jobOptions = {
removeOnComplete: true,
jobId: this.generateJobId(type as ObjectType, data) // 使用唯一ID防止重复任务
};
switch (type) {
case ObjectType.ENROLLMENT:
await this.generalQueue.add(
QueueJobType.UPDATE_STATS,
{
courseId: data.courseId,
type: ObjectType.ENROLLMENT
},
jobOptions
);
break;
constructor(@InjectQueue('general') private generalQueue: Queue) {
EventBus.on('dataChanged', async ({ type, data }) => {
const jobOptions = {
removeOnComplete: true,
jobId: this.generateJobId(type as ObjectType, data), // 使用唯一ID防止重复任务
};
switch (type) {
case ObjectType.ENROLLMENT:
await this.generalQueue.add(
QueueJobType.UPDATE_STATS,
{
courseId: data.courseId,
type: ObjectType.ENROLLMENT,
},
jobOptions,
);
break;
case ObjectType.LECTURE:
await this.generalQueue.add(
QueueJobType.UPDATE_STATS,
{
sectionId: data.sectionId,
courseId: data.courseId,
type: ObjectType.LECTURE
},
jobOptions
);
break;
case ObjectType.LECTURE:
await this.generalQueue.add(
QueueJobType.UPDATE_STATS,
{
sectionId: data.sectionId,
courseId: data.courseId,
type: ObjectType.LECTURE,
},
jobOptions,
);
break;
case ObjectType.POST:
if (data.courseId) {
await this.generalQueue.add(
QueueJobType.UPDATE_STATS,
{
courseId: data.courseId,
type: ObjectType.POST
},
jobOptions
);
}
break;
}
});
case ObjectType.POST:
if (data.courseId) {
await this.generalQueue.add(
QueueJobType.UPDATE_STATS,
{
courseId: data.courseId,
type: ObjectType.POST,
},
jobOptions,
);
}
break;
}
});
}
private generateJobId(type: ObjectType, data: any): string {
// 根据类型和相关ID生成唯一的job标识
switch (type) {
case ObjectType.ENROLLMENT:
return `stats_${type}_${data.courseId}`;
case ObjectType.LECTURE:
return `stats_${type}_${data.courseId}_${data.sectionId}`;
case ObjectType.POST:
return `stats_${type}_${data.courseId}`;
default:
return `stats_${type}_${Date.now()}`;
}
private generateJobId(type: ObjectType, data: any): string {
// 根据类型和相关ID生成唯一的job标识
switch (type) {
case ObjectType.ENROLLMENT:
return `stats_${type}_${data.courseId}`;
case ObjectType.LECTURE:
return `stats_${type}_${data.courseId}_${data.sectionId}`;
case ObjectType.POST:
return `stats_${type}_${data.courseId}`;
default:
return `stats_${type}_${Date.now()}`;
}
}
}
}
}

View File

@ -1,4 +1,10 @@
import { VisitType } from 'packages/common/dist';
export enum QueueJobType {
UPDATE_STATS = "update_stats",
FILE_PROCESS = "file_process"
UPDATE_STATS = 'update_stats',
FILE_PROCESS = 'file_process',
UPDATE_POST_VISIT_COUNT = 'updatePostVisitCount',
}
export type updateVisitCountJobData = {
id: string;
type: VisitType;
};

View File

@ -6,17 +6,17 @@ import { ImageProcessor } from '@server/models/resource/processor/ImageProcessor
import { VideoProcessor } from '@server/models/resource/processor/VideoProcessor';
const logger = new Logger('FileProcessorWorker');
const pipeline = new ResourceProcessingPipeline()
.addProcessor(new ImageProcessor())
.addProcessor(new VideoProcessor())
.addProcessor(new ImageProcessor())
.addProcessor(new VideoProcessor());
export default async function processJob(job: Job<any, any, QueueJobType>) {
if (job.name === QueueJobType.FILE_PROCESS) {
console.log(job)
const { resource } = job.data;
if (!resource) {
throw new Error('No resource provided in job data');
}
const result = await pipeline.execute(resource);
return result;
if (job.name === QueueJobType.FILE_PROCESS) {
console.log(job);
const { resource } = job.data;
if (!resource) {
throw new Error('No resource provided in job data');
}
}
const result = await pipeline.execute(resource);
return result;
}
}

View File

@ -4,6 +4,7 @@ import { Logger } from '@nestjs/common';
import { ObjectType } from '@nice/common';
import { QueueJobType } from '../types';
import { updatePostViewCount } from '../models/post/utils';
const logger = new Logger('QueueWorker');
export default async function processJob(job: Job<any, any, QueueJobType>) {
try {
@ -37,6 +38,9 @@ export default async function processJob(job: Job<any, any, QueueJobType>) {
`Updated course stats for courseId: ${courseId}, type: ${type}`,
);
}
if (job.name === QueueJobType.UPDATE_POST_VISIT_COUNT) {
await updatePostViewCount(job.data.id, job.data.type);
}
} catch (error: any) {
logger.error(
`Error processing stats update job: ${error.message}`,

View File

@ -47,9 +47,9 @@ export class TrpcService {
// Define a protected procedure that ensures the user is authenticated
protectProcedure = this.procedure.use(async ({ ctx, next }) => {
if (!ctx?.staff) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '未授权请求' });
}
// if (!ctx?.staff) {
// throw new TRPCError({ code: 'UNAUTHORIZED', message: '未授权请求' });
// }
return next({
ctx: {
// User value is confirmed to be non-null at this point

View File

@ -1,16 +1,25 @@
import mitt from 'mitt';
import { ObjectType, UserProfile, MessageDto } from '@nice/common';
import { ObjectType, UserProfile, MessageDto, VisitType } from '@nice/common';
export enum CrudOperation {
CREATED,
UPDATED,
DELETED
DELETED,
}
type Events = {
genDataEvent: { type: "start" | "end" },
markDirty: { objectType: string, id: string, staff?: UserProfile, subscribers?: string[] }
updateViewCount: { id: string, objectType: ObjectType },
onMessageCreated: { data: Partial<MessageDto> },
dataChanged: { type: string, operation: CrudOperation, data: any }
genDataEvent: { type: 'start' | 'end' };
markDirty: {
objectType: string;
id: string;
staff?: UserProfile;
subscribers?: string[];
};
updateVisitCount: {
id: string;
objectType: ObjectType;
visitType: VisitType;
};
onMessageCreated: { data: Partial<MessageDto> };
dataChanged: { type: string; operation: CrudOperation; data: any };
};
const EventBus = mitt<Events>();
export default EventBus;

View File

@ -64,6 +64,7 @@
"react-dropzone": "^14.3.5",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.4.1",
"react-intersection-observer": "^9.15.1",
"react-resizable": "^3.0.5",
"react-router-dom": "^6.24.1",
"superjson": "^2.2.1",

View File

@ -1,11 +1,8 @@
import PostDetail from "@web/src/components/models/post/detail/PostDetail";
import { useParams } from "react-router-dom";
export default function LetterDetailPage() {
const { id } = useParams();
return (
<>
<div>{id}</div>
</>
);
return <PostDetail id={id}></PostDetail>;
}

View File

@ -1,46 +1,73 @@
export default function Header() {
return <header className="bg-gradient-to-r from-[#00308F] to-[#0353A4] text-white p-6">
<div className="flex flex-col space-y-6">
{/* 主标题 */}
<div>
<h1 className="text-3xl font-bold tracking-wider">
</h1>
<p className="mt-2 text-blue-100 text-lg">
</p>
</div>
return (
<header className="bg-gradient-to-r from-[#00308F] to-[#0353A4] text-white p-6">
<div className="flex flex-col space-y-6">
{/* 主标题 */}
<div>
<h1 className="text-3xl font-bold tracking-wider">
</h1>
<p className="mt-2 text-blue-100 text-lg">
</p>
</div>
{/* 隐私保护说明 */}
<div className="flex flex-wrap gap-6 text-sm">
<div className="flex items-center gap-2">
<svg className="w-5 h-5" 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>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5" 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>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg className="w-5 h-5" 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>
<span></span>
</div>
</div>
{/* 隐私保护说明 */}
<div className="flex flex-wrap gap-6 text-sm">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
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>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
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>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
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>
<span></span>
</div>
</div>
{/* 隐私承诺 */}
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
<p></p>
</div>
</div>
</header>
}
{/* 隐私承诺 */}
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
<p>
</p>
</div>
</div>
</header>
);
}

View File

@ -1,11 +1,12 @@
export const defaultModules = {
toolbar: [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
[{ 'color': [] }, { 'background': [] }],
[{ 'align': [] }],
['link', 'image'],
['clean']
]
};
toolbar: [
[{ header: [1, 2, 3, 4, 5, 6, false] }],
["bold", "italic", "underline", "strike"],
[{ list: "ordered" }, { list: "bullet" }],
[{ color: [] }, { background: [] }],
[{ align: [] }],
["link"],
// ['link', 'image'],
["clean"],
],
};

View File

@ -83,7 +83,7 @@ export function LetterFormProvider({
: undefined,
},
});
// navigate(`/course/${result.id}/editor`, { replace: true });
navigate(`/course/${result.id}/detail`, { replace: true });
toast.success("发送成功!");
methods.reset(data);

View File

@ -60,8 +60,8 @@ export function LetterBasicForm() {
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}>
{/* 收件人 */}
{
{/* 收件人和板块信息行 */}
<div className="flex justify-start items-center gap-8">
<motion.div
custom={0}
initial="hidden"
@ -71,9 +71,7 @@ export function LetterBasicForm() {
<UserIcon className="w-5 h-5 mr-2 text-[#00308F]" />
<div>{receiver?.showname}</div>
</motion.div>
}
{/* 选择板块 */}
{
<motion.div
custom={1}
initial="hidden"
@ -83,7 +81,7 @@ export function LetterBasicForm() {
<FolderIcon className="w-5 h-5 mr-2 text-[#00308F]" />
<div>{term?.name}</div>
</motion.div>
}
</div>
{/* 主题输入框 */}
<motion.div
custom={2}
@ -143,11 +141,11 @@ export function LetterBasicForm() {
className="flex justify-end">
<FormCheckbox
name="isPublic"
label="公开信件"
label="是否公开"
defaultChecked
/>
</motion.div>
{/*
<motion.div
custom={5}
initial="hidden"
@ -156,13 +154,12 @@ export function LetterBasicForm() {
className="flex justify-end">
<FormSignature
name="meta.signature"
width="w-32"
placeholder="添加个性签名"
maxLength={20}
viewMode={false}
/>
</motion.div>
</motion.div> */}
<motion.button
onClick={handleSubmit(onSubmit)}
@ -170,7 +167,7 @@ export function LetterBasicForm() {
transform transition-all duration-200 hover:scale-105 focus:ring-2 focus:ring-[#00308F]/50"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}>
</motion.button>
</motion.div>
</motion.form>

View File

@ -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"
/>
</svg>
<span></span>
<span></span>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,11 @@
import { useEffect } from "react";
import { PostDetailProvider } from "./context/PostDetailContext";
import PostDetailLayout from "./layout/PostDetailLayout";
export default function PostDetail({ id }: { id?: string }) {
return (
<>
<PostDetailProvider editId={id}>

View File

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

View File

@ -1,12 +1,13 @@
import { api, usePost } from "@nice/client";
import { Post } from "@nice/common";
import { Post, postDetailSelect, PostDto, UserProfile } from "@nice/common";
import { useAuth } from "@web/src/providers/auth-provider";
import React, { createContext, ReactNode, useState } from "react";
import { string } from "zod";
interface PostDetailContextType {
editId?: string; // 添加 editId
post?: Post;
post?: PostDto;
isLoading?: boolean;
user?: UserProfile;
}
interface PostFormProviderProps {
children: ReactNode;
@ -19,15 +20,17 @@ export function PostDetailProvider({
children,
editId,
}: PostFormProviderProps) {
const { data: post, isLoading }: { data: Post; isLoading: boolean } = (
const { user } = useAuth();
const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = (
api.post.findFirst as any
).useQuery(
{
where: { id: editId },
select: postDetailSelect,
},
{ enabled: Boolean(editId) }
);
// const {}:{} =(
// const {}:{} =(
// api.post.fin as any
// )
@ -36,7 +39,7 @@ export function PostDetailProvider({
value={{
editId,
post,
user,
isLoading,
}}>
{children}

View File

@ -1,12 +1,34 @@
import { motion } from "framer-motion";
import { useContext } from "react";
import { useContext, useEffect } from "react";
import { PostDetailContext } from "../context/PostDetailContext";
import PostHeader from "../PostHeader";
import PostCommentEditor from "../PostCommentEditor";
import PostCommentList from "../PostCommentList";
import { useVisitor } from "@nice/client";
import { useAuth } from "@web/src/providers/auth-provider";
import { VisitType } from "@nice/common";
export default function PostDetailLayout() {
const { post } = useContext(PostDetailContext);
const { post, user } = useContext(PostDetailContext);
const { read } = useVisitor();
return <div>
</div>;
useEffect(() => {
if (post) {
console.log("read");
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>
);
}

View File

@ -0,0 +1,7 @@
export const isContentEmpty = (html: string) => {
// 创建一个临时 div 来解析 HTML 内容
const temp = document.createElement("div");
temp.innerHTML = html;
// 获取纯文本内容并检查是否为空
return !temp.textContent?.trim();
};

View File

@ -6,13 +6,13 @@
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom: none;
border: none
border: none;
}
.quill-editor-container .ql-container {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
border: none
border: none;
}
.ag-custom-dragging-class {
@ -45,11 +45,11 @@
background-color: transparent !important;
}
.ant-table-thead>tr>th {
.ant-table-thead > tr > th {
background-color: transparent !important;
}
.ant-table-tbody>tr>td {
.ant-table-tbody > tr > td {
background-color: transparent !important;
border-bottom-color: transparent !important;
}
@ -86,7 +86,9 @@
}
/* 覆盖 Ant Design 的默认样式 raido button左侧的按钮*/
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled)::before {
.ant-radio-button-wrapper-checked:not(
.ant-radio-button-wrapper-disabled
)::before {
background-color: unset !important;
}
@ -99,7 +101,7 @@
display: none !important;
}
.no-wrap-header .ant-table-thead>tr>th {
.no-wrap-header .ant-table-thead > tr > th {
white-space: nowrap;
}
@ -115,12 +117,206 @@
/* 设置单元格边框 */
}
.custom-table .ant-table-tbody>tr>td {
.custom-table .ant-table-tbody > tr > td {
border-bottom: 1px solid #ddd;
/* 设置表格行底部边框 */
}
.custom-table .ant-table-tbody>tr:last-child>td {
.custom-table .ant-table-tbody > tr:last-child > td {
border-bottom: none;
/* 去除最后一行的底部边框 */
}
.quill-editor-container .ql-toolbar.ql-snow,
.quill-editor-container .ql-container.ql-snow {
border-color: transparent;
}
.quill-editor-container .ql-toolbar.ql-snow {
background: rgb(248, 250, 252);
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
.quill-editor-container .ql-container.ql-snow {
background: transparent;
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
}
.quill-editor-container .ql-editor {
min-height: 120px;
color: rgb(30, 41, 59); /* slate-800 */
}
.quill-editor-container .ql-editor.ql-blank::before {
color: rgb(100, 116, 139); /* slate-500 */
}
.ql-editor {
/* 代码块容器 */
.ql-code-block-container {
background: #1e293b;
color: #e2e8f0;
border-radius: 0.5rem;
margin: 0; /* 更新 */
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
monospace;
/* 代码块内容 */
.ql-code-block {
padding: 0.2rem;
font-size: 0.875rem;
line-height: 1.2;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
}
/* 代码块 */
pre.ql-syntax {
background: #1e293b;
color: #e2e8f0;
border-radius: 0.5rem;
padding: 1rem;
margin: 0; /* 更新 */
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
monospace;
font-size: 0.875rem;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
/* 引用块 */
blockquote {
border-left: 4px solid #3b82f6;
background: #f8fafc;
padding: 1rem 1.2rem;
margin: 0; /* 更新 */
color: #475569;
font-style: italic;
/* 嵌套引用 */
blockquote {
border-left-color: #64748b;
background: #f1f5f9;
margin: 0; /* 更新 */
}
}
/* 有序列表 */
ol {
list-style-type: decimal;
padding-left: 2rem;
margin: 0; /* 更新 */
/* 嵌套有序列表 */
ol {
list-style-type: lower-alpha;
margin: 0; /* 更新 */
ol {
list-style-type: lower-roman;
}
}
li {
padding-left: 0.5rem;
margin-bottom: 0; /* 更新 */
&::marker {
color: #3b82f6;
font-weight: 600;
}
}
}
/* 无序列表 */
ul {
list-style-type: disc;
padding-left: 2rem;
margin: 0; /* 更新 */
/* 嵌套无序列表 */
ul {
list-style-type: circle;
margin: 0; /* 更新 */
ul {
list-style-type: square;
}
}
li {
padding-left: 0.5rem;
margin-bottom: 0; /* 更新 */
&::marker {
color: #3b82f6;
}
}
}
/* 标题 */
h1,
h2,
h3 {
color: #1e3a8a;
font-weight: 600;
line-height: 1.25;
margin: 0; /* 更新 */
}
h1 {
font-size: 2em;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 0.5rem;
}
h2 {
font-size: 1.5em;
}
h3 {
font-size: 1.25em;
}
/* 分割线 */
hr {
border: 0;
border-top: 2px solid #e2e8f0;
margin: 0; /* 更新 */
}
/* 段落 */
p {
margin: 0; /* 更新 */
line-height: 1.2;
}
/* 表格 */
table {
border-collapse: collapse;
width: 100%;
margin: 0; /* 更新 */
th,
td {
border: 1px solid #e2e8f0;
padding: 0.75rem;
text-align: left;
}
th {
background: #f8fafc;
font-weight: 600;
}
tr:nth-child(even) {
background: #f8fafc;
}
}
}

View File

@ -1,14 +1,14 @@
import { api } from "../trpc";
import { TroubleParams } from "../../singleton/DataHolder";
import { PostParams } from "../../singleton/DataHolder";
export function useVisitor() {
const utils = api.useUtils();
const troubleParams = TroubleParams.getInstance();
const postParams = PostParams.getInstance();
const create = api.visitor.create.useMutation({
onSuccess() {
utils.visitor.invalidate();
// utils.trouble.invalidate();
// utils.post.invalidate();
},
});
/**
@ -19,68 +19,71 @@ export function useVisitor() {
const createOptimisticMutation = (
updateFn: (item: any, variables: any) => any
) => ({
// 在请求发送前执行本地数据预更新
// onMutate: async (variables: any) => {
// const previousDataList: any[] = [];
// // 动态生成参数列表,包括星标和其他参数
//在请求发送前执行本地数据预更新
onMutate: async (variables: any) => {
const previousDataList: any[] = [];
// 动态生成参数列表,包括星标和其他参数
// const paramsList = troubleParams.getItems();
// console.log(paramsList.length);
// // 遍历所有参数列表,执行乐观更新
// for (const params of paramsList) {
// // 取消可能的并发请求
// await utils.trouble.findManyWithCursor.cancel();
// // 获取并保存当前数据
// const previousData =
// utils.trouble.findManyWithCursor.getInfiniteData({
// ...params,
// });
// previousDataList.push(previousData);
// // 执行乐观更新
// utils.trouble.findManyWithCursor.setInfiniteData(
// {
// ...params,
// },
// (oldData) => {
// if (!oldData) return oldData;
// return {
// ...oldData,
// pages: oldData.pages.map((page) => ({
// ...page,
// items: page.items.map((item) =>
// item.id === variables?.troubleId
// ? updateFn(item, variables)
// : item
// ),
// })),
// };
// }
// );
// }
const paramsList = postParams.getItems();
console.log("paramsList.length", paramsList.length);
// 遍历所有参数列表,执行乐观更新
for (const params of paramsList) {
// 取消可能的并发请求
await utils.post.findManyWithCursor.cancel();
// 获取并保存当前数据
const previousData =
utils.post.findManyWithCursor.getInfiniteData({
...params,
});
previousDataList.push(previousData);
// 执行乐观更新
utils.post.findManyWithCursor.setInfiniteData(
{
...params,
},
(oldData) => {
if (!oldData) return oldData;
return {
...oldData,
pages: oldData.pages.map((page) => ({
...page,
items: (page.items as any).map((item) =>
item.id === variables?.postId
? updateFn(item, variables)
: item
),
})),
};
}
);
}
// return { previousDataList };
// },
// // 错误处理:数据回滚
// onError: (_err: any, _variables: any, context: any) => {
// const paramsList = troubleParams.getItems();
// paramsList.forEach((params, index) => {
// if (context?.previousDataList?.[index]) {
// utils.trouble.findManyWithCursor.setInfiniteData(
// { ...params },
// context.previousDataList[index]
// );
// }
// });
// },
// // 成功后的缓存失效
// onSuccess: (_: any, variables: any) => {
// utils.visitor.invalidate();
// utils.trouble.findFirst.invalidate({
// where: {
// id: (variables as any)?.troubleId,
// },
// });
// },
return { previousDataList };
},
// 错误处理:数据回滚
onError: (_err: any, _variables: any, context: any) => {
const paramsList = postParams.getItems();
paramsList.forEach((params, index) => {
if (context?.previousDataList?.[index]) {
utils.post.findManyWithCursor.setInfiniteData(
{ ...params },
context.previousDataList[index]
);
}
});
},
// 成功后的缓存失效
onSuccess: async (_: any, variables: any) => {
await Promise.all([
utils.visitor.invalidate(),
utils.post.findFirst.invalidate({
where: {
id: (variables as any)?.postId,
},
}),
utils.post.findManyWithCursor.invalidate(),
]);
},
});
// 定义具体的mutation
const read = api.visitor.create.useMutation(
@ -90,6 +93,13 @@ export function useVisitor() {
readed: true,
}))
);
const like = api.visitor.create.useMutation(
createOptimisticMutation((item) => ({
...item,
likes: (item.likes || 0) + 1,
liked: true,
}))
);
const addStar = api.visitor.create.useMutation(
createOptimisticMutation((item) => ({
@ -120,12 +130,13 @@ export function useVisitor() {
});
return {
troubleParams,
postParams,
create,
createMany,
deleteMany,
read,
addStar,
deleteStar,
like,
};
}

View File

@ -1,36 +1,53 @@
export class TroubleParams {
private static instance: TroubleParams; // 静态私有变量,用于存储单例实例
private troubleParams: Array<object>; // 私有数组属性,用于存储对象
export class PostParams {
private static instance: PostParams; // 静态私有变量,用于存储单例实例
private postParams: Array<object>; // 私有数组属性,用于存储对象
private constructor() {
this.troubleParams = []; // 初始化空数组
this.postParams = []; // 初始化空数组
}
public static getInstance(): TroubleParams {
if (!TroubleParams.instance) {
TroubleParams.instance = new TroubleParams();
public static getInstance(): PostParams {
if (!PostParams.instance) {
PostParams.instance = new PostParams();
}
return TroubleParams.instance;
return PostParams.instance;
}
public addItem(item: object): void {
// 代码意图解析: 向数组中添加一个对象,确保不会添加重复的对象。
// 技术原理阐述: 在添加对象之前,使用 `some` 方法检查数组中是否已经存在相同的对象。如果不存在,则添加到数组中。
// 数据结构解读: `some` 方法遍历数组,检查是否存在满足条件的元素。`JSON.stringify` 用于将对象转换为字符串进行比较。
// 算法复杂度分析: `some` 方法的复杂度为 O(n),因为需要遍历数组中的每个元素。`JSON.stringify` 的复杂度取决于对象的大小,通常为 O(m),其中 m 是对象的属性数量。因此,总复杂度为 O(n * m)。
// 可能的优化建议: 如果数组非常大,可以考虑使用哈希表(如 `Map` 或 `Set`)来存储对象的唯一标识符,以提高查找效率。
const isDuplicate = this.troubleParams.some(
(existingItem) =>
JSON.stringify(existingItem) === JSON.stringify(item)
);
// 使用更可靠的方式比较查询参数
const isDuplicate = this.postParams.some((existingItem: any) => {
if (item && existingItem) {
const itemWhere = (item as any).where;
const existingWhere = existingItem.where;
return (
itemWhere?.parentId === existingWhere?.parentId &&
itemWhere?.type === existingWhere?.type
);
}
return false;
});
if (!isDuplicate) {
this.troubleParams.push(item);
this.postParams.push(item);
}
}
public removeItem(item: object): void {
// 使用相同的比较逻辑移除项
this.postParams = this.postParams.filter((existingItem: any) => {
if (item && existingItem) {
const itemWhere = (item as any).where;
const existingWhere = existingItem.where;
return !(
itemWhere?.parentId === existingWhere?.parentId &&
itemWhere?.type === existingWhere?.type
);
}
return true;
});
}
public getItems(): Array<object> {
return [...this.troubleParams]; // 返回数组的副本,防止外部直接修改原数组
return [...this.postParams];
}
}

View File

@ -187,6 +187,7 @@ model Post {
// 字符串类型字段
id String @id @default(cuid()) // 帖子唯一标识,使用 cuid() 生成默认值
type String? // 帖子类型,可为空
state String? // 状态 未读、处理中、已回答
title String? // 帖子标题,可为空
content String? // 帖子内容,可为空
domainId String? @map("domain_id")
@ -194,12 +195,15 @@ model Post {
termId String? @map("term_id")
// 日期时间类型字段
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedAt DateTime @map("updated_at")
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
// 关系类型字段
authorId String? @map("author_id")
author Staff? @relation("post_author", fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型
visits Visit[] // 访问记录,关联 Visit 模型
views Int @default(0)
likes Int @default(0)
receivers Staff[] @relation("post_receiver")
parentId String? @map("parent_id")
parent Post? @relation("PostChildren", fields: [parentId], references: [id]) // 父级帖子,关联 Post 模型
@ -242,8 +246,8 @@ model Visit {
views Int @default(1) @map("views")
// sourceIP String? @map("source_ip")
// 关联关系
visitorId String @map("visitor_id")
visitor Staff @relation(fields: [visitorId], references: [id])
visitorId String? @map("visitor_id")
visitor Staff? @relation(fields: [visitorId], references: [id])
postId String? @map("post_id")
post Post? @relation(fields: [postId], references: [id])
message Message? @relation(fields: [messageId], references: [id])

View File

@ -4,7 +4,7 @@ export enum SocketMsgType {
export enum PostType {
POST = "post",
POST_COMMENT = "post_comment",
COURSE_REVIEW = "course_review"
COURSE_REVIEW = "course_review",
}
export enum TaxonomySlug {
CATEGORY = "category",
@ -14,24 +14,24 @@ export enum TaxonomySlug {
export enum VisitType {
STAR = "star",
READED = "read",
LIKE = "like",
}
export enum StorageProvider {
LOCAL = 'LOCAL',
S3 = 'S3',
OSS = 'OSS',
COS = 'COS',
CDN = 'CDN'
LOCAL = "LOCAL",
S3 = "S3",
OSS = "OSS",
COS = "COS",
CDN = "CDN",
}
export enum ResourceStatus {
UPLOADING = "UPLOADING",
UPLOADED = "UPLOADED",
PROCESS_PENDING = 'PROCESS_PENDING',
PROCESSING = 'PROCESSING',
PROCESSED = 'PROCESSED',
PROCESS_FAILED = 'PROCESS_FAILED'
PROCESS_PENDING = "PROCESS_PENDING",
PROCESSING = "PROCESSING",
PROCESSED = "PROCESSED",
PROCESS_FAILED = "PROCESS_FAILED",
}
export enum ObjectType {
DEPARTMENT = "department",
@ -48,7 +48,7 @@ export enum ObjectType {
SECTION = "section",
LECTURE = "lecture",
ENROLLMENT = "enrollment",
RESOURCE = "resource"
RESOURCE = "resource",
}
export enum RolePerms {
// Create Permissions 创建权限
@ -94,100 +94,99 @@ export enum AppConfigSlug {
}
// 资源类型的枚举,定义了不同类型的资源,以字符串值表示
export enum ResourceType {
VIDEO = "video", // 视频资源
PDF = "pdf", // PDF文档
DOC = "doc", // Word文档
EXCEL = "excel", // Excel表格
PPT = "ppt", // PowerPoint演示文稿
CODE = "code", // 代码文件
LINK = "link", // 超链接
IMAGE = "image", // 图片资源
AUDIO = "audio", // 音频资源
ZIP = "zip", // 压缩包文件
OTHER = "other" // 其他未分类资源
VIDEO = "video", // 视频资源
PDF = "pdf", // PDF文档
DOC = "doc", // Word文档
EXCEL = "excel", // Excel表格
PPT = "ppt", // PowerPoint演示文稿
CODE = "code", // 代码文件
LINK = "link", // 超链接
IMAGE = "image", // 图片资源
AUDIO = "audio", // 音频资源
ZIP = "zip", // 压缩包文件
OTHER = "other", // 其他未分类资源
}
// 课程等级的枚举,描述了不同学习水平的课程
export enum CourseLevel {
BEGINNER = "beginner", // 初级课程,适合初学者
INTERMEDIATE = "intermediate", // 中级课程,适合有一定基础的学习者
ADVANCED = "advanced", // 高级课程,适合高级水平学习者
ALL_LEVELS = "all_levels" // 适用于所有学习水平的课程
BEGINNER = "beginner", // 初级课程,适合初学者
INTERMEDIATE = "intermediate", // 中级课程,适合有一定基础的学习者
ADVANCED = "advanced", // 高级课程,适合高级水平学习者
ALL_LEVELS = "all_levels", // 适用于所有学习水平的课程
}
// 课时(课程内容)类型的枚举,定义了课程中可能包含的不同内容形式
export enum LessonType {
VIDEO = "video", // 视频课程
ARTICLE = "article", // 文章型课程内容
QUIZ = "quiz", // 测验类型
VIDEO = "video", // 视频课程
ARTICLE = "article", // 文章型课程内容
QUIZ = "quiz", // 测验类型
ASSIGNMENT = "assignment", // 作业类型
}
// 课程状态的枚举,定义了课程生命周期中的各个状态
export enum CourseStatus {
DRAFT = "draft", // 草稿状态的课程,尚未发布
UNDER_REVIEW = "under_review", // 正在审核中的课程
PUBLISHED = "published", // 已发布的课程,可以被学员报名学习
ARCHIVED = "archived" // 已归档的课程,不再对外展示
DRAFT = "draft", // 草稿状态的课程,尚未发布
UNDER_REVIEW = "under_review", // 正在审核中的课程
PUBLISHED = "published", // 已发布的课程,可以被学员报名学习
ARCHIVED = "archived", // 已归档的课程,不再对外展示
}
export const CourseStatusLabel: Record<CourseStatus, string> = {
[CourseStatus.DRAFT]: "草稿",
[CourseStatus.UNDER_REVIEW]: "审核中",
[CourseStatus.PUBLISHED]: "已发布",
[CourseStatus.ARCHIVED]: "已归档"
[CourseStatus.ARCHIVED]: "已归档",
};
// 报名状态的枚举,描述了用户报名参加课程的不同状态
export enum EnrollmentStatus {
PENDING = "pending", // 报名待处理状态
ACTIVE = "active", // 活跃状态,用户可参与课程
PENDING = "pending", // 报名待处理状态
ACTIVE = "active", // 活跃状态,用户可参与课程
COMPLETED = "completed", // 完成状态,用户已完成课程
CANCELLED = "cancelled", // 已取消的报名
REFUNDED = "refunded" // 已退款的报名
REFUNDED = "refunded", // 已退款的报名
}
// 授课角色的枚举,定义了讲师在课程中的角色分配
export enum InstructorRole {
MAIN = "main", // 主讲教师
ASSISTANT = "assistant" // 助教
MAIN = "main", // 主讲教师
ASSISTANT = "assistant", // 助教
}
export const EnrollmentStatusLabel = {
[EnrollmentStatus.PENDING]: '待处理',
[EnrollmentStatus.ACTIVE]: '进行中',
[EnrollmentStatus.COMPLETED]: '已完成',
[EnrollmentStatus.CANCELLED]: '已取消',
[EnrollmentStatus.REFUNDED]: '已退款'
[EnrollmentStatus.PENDING]: "待处理",
[EnrollmentStatus.ACTIVE]: "进行中",
[EnrollmentStatus.COMPLETED]: "已完成",
[EnrollmentStatus.CANCELLED]: "已取消",
[EnrollmentStatus.REFUNDED]: "已退款",
};
export const InstructorRoleLabel = {
[InstructorRole.MAIN]: '主讲教师',
[InstructorRole.ASSISTANT]: '助教'
[InstructorRole.MAIN]: "主讲教师",
[InstructorRole.ASSISTANT]: "助教",
};
export const ResourceTypeLabel = {
[ResourceType.VIDEO]: '视频',
[ResourceType.PDF]: 'PDF文档',
[ResourceType.DOC]: 'Word文档',
[ResourceType.EXCEL]: 'Excel表格',
[ResourceType.PPT]: 'PPT演示文稿',
[ResourceType.CODE]: '代码文件',
[ResourceType.LINK]: '链接',
[ResourceType.IMAGE]: '图片',
[ResourceType.AUDIO]: '音频',
[ResourceType.ZIP]: '压缩包',
[ResourceType.OTHER]: '其他'
[ResourceType.VIDEO]: "视频",
[ResourceType.PDF]: "PDF文档",
[ResourceType.DOC]: "Word文档",
[ResourceType.EXCEL]: "Excel表格",
[ResourceType.PPT]: "PPT演示文稿",
[ResourceType.CODE]: "代码文件",
[ResourceType.LINK]: "链接",
[ResourceType.IMAGE]: "图片",
[ResourceType.AUDIO]: "音频",
[ResourceType.ZIP]: "压缩包",
[ResourceType.OTHER]: "其他",
};
export const CourseLevelLabel = {
[CourseLevel.BEGINNER]: '初级',
[CourseLevel.INTERMEDIATE]: '中级',
[CourseLevel.ADVANCED]: '高级',
[CourseLevel.ALL_LEVELS]: '不限级别'
[CourseLevel.BEGINNER]: "初级",
[CourseLevel.INTERMEDIATE]: "中级",
[CourseLevel.ADVANCED]: "高级",
[CourseLevel.ALL_LEVELS]: "不限级别",
};
export const LessonTypeLabel = {
[LessonType.VIDEO]: '视频课程',
[LessonType.ARTICLE]: '图文课程',
[LessonType.QUIZ]: '测验',
[LessonType.ASSIGNMENT]: '作业'
[LessonType.VIDEO]: "视频课程",
[LessonType.ARTICLE]: "图文课程",
[LessonType.QUIZ]: "测验",
[LessonType.ASSIGNMENT]: "作业",
};

View File

@ -1,2 +1,46 @@
import { Prisma } from "@prisma/client";
export const postDetailSelect: Prisma.PostSelect = {
id: true,
type: true,
title: true,
content: true,
views: true,
likes: true,
resources: true,
createdAt: true,
updatedAt: true,
termId: true,
term: {
include: {
taxonomy: true,
},
},
author: {
select: {
id: true,
showname: true,
avatar: true,
department: {
select: {
id: true,
name: true,
},
},
},
},
receivers: {
select: {
id: true,
showname: true,
avatar: true,
department: {
select: {
id: true,
name: true,
},
},
},
},
meta: true,
};

View File

@ -5,6 +5,7 @@ import type {
Message,
Post,
RoleMap,
Resource,
} from "@prisma/client";
import { SocketMsgType, RolePerms } from "./enum";
import { RowRequestSchema } from "./schema";
@ -125,16 +126,20 @@ export type PostComment = {
};
export type PostDto = Post & {
readed: boolean;
liked: boolean;
readedCount: number;
author: StaffDto;
limitedComments: PostComment[];
commentsCount: number;
term: TermDto;
author: StaffDto | undefined;
receivers: StaffDto[];
resources: Resource[];
perms?: {
delete: boolean;
// edit: boolean;
};
watchableDepts: Department[];
watchableStaffs: Staff[];
views: number;
meta?: PostMeta;
};
export type TermDto = Term & {
@ -161,6 +166,7 @@ export interface BaseSetting {
export interface PostMeta {
signature?: string;
ip?: string;
tags?: string[];
}
export type RowModelResult = {
rowData: any[];

View File

@ -12,7 +12,7 @@ importers:
dependencies:
'@nestjs/bullmq':
specifier: ^10.2.0
version: 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(bullmq@5.34.8)
version: 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(bullmq@5.34.8)
'@nestjs/common':
specifier: ^10.3.10
version: 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
@ -33,7 +33,7 @@ importers:
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.15)(rxjs@7.8.1)
'@nestjs/schedule':
specifier: ^4.1.0
version: 4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)
version: 4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))
'@nestjs/websockets':
specifier: ^10.3.10
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-socket.io@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
@ -148,7 +148,7 @@ importers:
version: 10.2.3(chokidar@3.6.0)(typescript@5.7.2)
'@nestjs/testing':
specifier: ^10.0.0
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-express@10.4.15)
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15))
'@types/exceljs':
specifier: ^1.3.0
version: 1.3.2
@ -386,6 +386,9 @@ importers:
react-hot-toast:
specifier: ^2.4.1
version: 2.5.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-intersection-observer:
specifier: ^9.15.1
version: 9.15.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-resizable:
specifier: ^3.0.5
version: 3.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -5946,6 +5949,15 @@ packages:
react: '>=16.8.1'
react-dom: '>=16.8.1'
react-intersection-observer@9.15.1:
resolution: {integrity: sha512-vGrqYEVWXfH+AGu241uzfUpNK4HAdhCkSAyFdkMb9VWWXs6mxzBLpWCxEy9YcnDNY2g9eO6z7qUtTBdA9hc8pA==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
react-dom:
optional: true
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -8538,15 +8550,15 @@ snapshots:
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
optional: true
'@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)':
'@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))':
dependencies:
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
tslib: 2.8.1
'@nestjs/bullmq@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(bullmq@5.34.8)':
'@nestjs/bullmq@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(bullmq@5.34.8)':
dependencies:
'@nestjs/bull-shared': 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)
'@nestjs/bull-shared': 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
bullmq: 5.34.8
@ -8643,7 +8655,7 @@ snapshots:
- supports-color
- utf-8-validate
'@nestjs/schedule@4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)':
'@nestjs/schedule@4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))':
dependencies:
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
@ -8661,7 +8673,7 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@nestjs/testing@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-express@10.4.15)':
'@nestjs/testing@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15))':
dependencies:
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
@ -13285,6 +13297,12 @@ snapshots:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-intersection-observer@9.15.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
react: 18.2.0
optionalDependencies:
react-dom: 18.2.0(react@18.2.0)
react-is@16.13.1: {}
react-is@17.0.2: {}