This commit is contained in:
longdayi 2025-01-24 15:05:26 +08:00
commit a71a88c30e
39 changed files with 1583 additions and 405 deletions

View File

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

View File

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

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

View File

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

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,16 +1,12 @@
import { InjectQueue } from "@nestjs/bullmq"; import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from "@nestjs/common"; import { Injectable } from '@nestjs/common';
import EventBus from "@server/utils/event-bus"; import EventBus from '@server/utils/event-bus';
import { Queue } from "bullmq"; import { Queue } from 'bullmq';
import { ObjectType } from "@nice/common"; import { ObjectType } from '@nice/common';
import { QueueJobType } from "../types"; import { QueueJobType } from '../types';
@Injectable() @Injectable()
export class PostProcessService { export class PostProcessService {
constructor( constructor(@InjectQueue('general') private generalQueue: Queue) {}
@InjectQueue('general') private generalQueue: Queue
) {
}
private generateJobId(type: ObjectType, data: any): string { private generateJobId(type: ObjectType, data: any): string {
// 根据类型和相关ID生成唯一的job标识 // 根据类型和相关ID生成唯一的job标识

View File

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

View File

@ -1,18 +1,16 @@
import { InjectQueue } from "@nestjs/bullmq"; import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from "@nestjs/common"; import { Injectable } from '@nestjs/common';
import EventBus from "@server/utils/event-bus"; import EventBus from '@server/utils/event-bus';
import { Queue } from "bullmq"; import { Queue } from 'bullmq';
import { ObjectType } from "@nice/common"; import { ObjectType } from '@nice/common';
import { QueueJobType } from "../types"; import { QueueJobType } from '../types';
@Injectable() @Injectable()
export class StatsService { export class StatsService {
constructor( constructor(@InjectQueue('general') private generalQueue: Queue) {
@InjectQueue('general') private generalQueue: Queue EventBus.on('dataChanged', async ({ type, data }) => {
) {
EventBus.on("dataChanged", async ({ type, data }) => {
const jobOptions = { const jobOptions = {
removeOnComplete: true, removeOnComplete: true,
jobId: this.generateJobId(type as ObjectType, data) // 使用唯一ID防止重复任务 jobId: this.generateJobId(type as ObjectType, data), // 使用唯一ID防止重复任务
}; };
switch (type) { switch (type) {
case ObjectType.ENROLLMENT: case ObjectType.ENROLLMENT:
@ -20,9 +18,9 @@ export class StatsService {
QueueJobType.UPDATE_STATS, QueueJobType.UPDATE_STATS,
{ {
courseId: data.courseId, courseId: data.courseId,
type: ObjectType.ENROLLMENT type: ObjectType.ENROLLMENT,
}, },
jobOptions jobOptions,
); );
break; break;
@ -32,9 +30,9 @@ export class StatsService {
{ {
sectionId: data.sectionId, sectionId: data.sectionId,
courseId: data.courseId, courseId: data.courseId,
type: ObjectType.LECTURE type: ObjectType.LECTURE,
}, },
jobOptions jobOptions,
); );
break; break;
@ -44,9 +42,9 @@ export class StatsService {
QueueJobType.UPDATE_STATS, QueueJobType.UPDATE_STATS,
{ {
courseId: data.courseId, courseId: data.courseId,
type: ObjectType.POST type: ObjectType.POST,
}, },
jobOptions jobOptions,
); );
} }
break; break;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,25 +14,49 @@ export default function Header() {
{/* 隐私保护说明 */} {/* 隐私保护说明 */}
<div className="flex flex-wrap gap-6 text-sm"> <div className="flex flex-wrap gap-6 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" className="w-5 h-5"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg> </svg>
<span></span> <span></span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" className="w-5 h-5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
<span></span> <span></span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" className="w-5 h-5"
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" /> fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"
/>
</svg> </svg>
<span></span> <span></span>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

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" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"
/> />
</svg> </svg>
<span></span> <span></span>
</div> </div>
</div> </div>

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 { PostDetailProvider } from "./context/PostDetailContext";
import PostDetailLayout from "./layout/PostDetailLayout"; import PostDetailLayout from "./layout/PostDetailLayout";
export default function PostDetail({ id }: { id?: string }) { export default function PostDetail({ id }: { id?: string }) {
return ( return (
<> <>
<PostDetailProvider editId={id}> <PostDetailProvider editId={id}>

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

View File

@ -1,12 +1,34 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useContext } from "react"; import { useContext, useEffect } from "react";
import { PostDetailContext } from "../context/PostDetailContext"; import { PostDetailContext } from "../context/PostDetailContext";
import PostHeader from "../PostHeader";
import PostCommentEditor from "../PostCommentEditor";
import PostCommentList from "../PostCommentList";
import { useVisitor } from "@nice/client";
import { useAuth } from "@web/src/providers/auth-provider";
import { VisitType } from "@nice/common";
export default function PostDetailLayout() { export default function PostDetailLayout() {
const { post } = useContext(PostDetailContext); const { post, user } = useContext(PostDetailContext);
const { read } = useVisitor();
return <div> useEffect(() => {
if (post) {
console.log("read");
</div>; read.mutateAsync({
data: {
visitorId: user?.id || null,
postId: post.id,
type: VisitType.READED,
},
});
}
}, [post]);
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 flex flex-col">
<PostHeader></PostHeader>
<PostCommentEditor></PostCommentEditor>
<PostCommentList></PostCommentList>
</div>
);
} }

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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