add 001749
This commit is contained in:
parent
7df1e997c1
commit
199d025cd9
|
@ -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 时间戳
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -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,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);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,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()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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"],
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 PostDetailLayout from "./layout/PostDetailLayout";
|
||||
|
||||
export default function PostDetail({ id }: { id?: string }) {
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 { 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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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]: "作业",
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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: {}
|
||||
|
|
Loading…
Reference in New Issue