Merge branch 'main' of http://113.45.157.195:3003/insiinc/leader-mail
This commit is contained in:
commit
d28793a15d
|
@ -7,11 +7,12 @@ import {
|
|||
RolePerms,
|
||||
ResPerm,
|
||||
ObjectType,
|
||||
PostType,
|
||||
} from '@nice/common';
|
||||
import { MessageService } from '../message/message.service';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import { DepartmentService } from '../department/department.service';
|
||||
import { setPostRelation } from './utils';
|
||||
import { setPostRelation, updatePostState } from './utils';
|
||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
|
||||
@Injectable()
|
||||
|
@ -22,6 +23,12 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
|
|||
) {
|
||||
super(db, ObjectType.POST);
|
||||
}
|
||||
onModuleInit() {
|
||||
EventBus.on('updatePostState', ({ id }) => {
|
||||
console.log('updatePostState');
|
||||
updatePostState(id);
|
||||
});
|
||||
}
|
||||
async create(
|
||||
args: Prisma.PostCreateArgs,
|
||||
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
|
||||
|
@ -36,6 +43,11 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
|
|||
operation: CrudOperation.CREATED,
|
||||
data: result,
|
||||
});
|
||||
if (args.data.authorId && args?.data?.type === PostType.POST_COMMENT) {
|
||||
EventBus.emit('updatePostState', {
|
||||
id: result?.id,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) {
|
||||
|
|
|
@ -1,4 +1,11 @@
|
|||
import { db, Post, PostType, UserProfile, VisitType } from '@nice/common';
|
||||
import {
|
||||
db,
|
||||
Post,
|
||||
PostState,
|
||||
PostType,
|
||||
UserProfile,
|
||||
VisitType,
|
||||
} from '@nice/common';
|
||||
|
||||
export async function setPostRelation(params: {
|
||||
data: Post;
|
||||
|
@ -71,3 +78,55 @@ export function getClientIp(req: any): string {
|
|||
|
||||
return ip || '';
|
||||
}
|
||||
export async function updatePostState(id: string) {
|
||||
const post = await db.post.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
state: true,
|
||||
receivers: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (post?.state === PostState.COMPLETED) {
|
||||
return;
|
||||
}
|
||||
const postReceiverIds = post.receivers
|
||||
.map((receiver) => receiver.id)
|
||||
.filter(Boolean);
|
||||
const receiverViews = await db.visit.count({
|
||||
where: {
|
||||
postId: id,
|
||||
type: VisitType.READED,
|
||||
visitorId: {
|
||||
in: postReceiverIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (receiverViews > 0 && post.state === PostState.PENDING) {
|
||||
await db.post.update({
|
||||
where: { id },
|
||||
data: { state: PostState.PROCESSING },
|
||||
});
|
||||
}
|
||||
const receiverComments = await db.post.count({
|
||||
where: {
|
||||
parentId: id,
|
||||
type: PostType.POST_COMMENT,
|
||||
authorId: {
|
||||
in: postReceiverIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (receiverComments > 0) {
|
||||
await db.post.update({
|
||||
where: { id },
|
||||
data: { state: PostState.COMPLETED },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,20 +58,26 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
if (postId) {
|
||||
if (visitorId) {
|
||||
EventBus.emit('updatePostState', {
|
||||
id: postId,
|
||||
});
|
||||
}
|
||||
if (args.data.type === VisitType.READED) {
|
||||
EventBus.emit('updateVisitCount', {
|
||||
objectType: ObjectType.POST,
|
||||
id: postId,
|
||||
visitType: VisitType.READED,
|
||||
});
|
||||
}
|
||||
if (args.data.type === VisitType.LIKE) {
|
||||
EventBus.emit('updateVisitCount', {
|
||||
objectType: ObjectType.POST,
|
||||
id: postId,
|
||||
visitType: VisitType.LIKE,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,11 @@ 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';
|
||||
import {
|
||||
QueueJobType,
|
||||
updatePostStateJobData,
|
||||
updateVisitCountJobData,
|
||||
} from '@server/queue/types';
|
||||
|
||||
@Injectable()
|
||||
export class PostQueueService implements OnModuleInit {
|
||||
|
@ -16,6 +20,10 @@ export class PostQueueService implements OnModuleInit {
|
|||
this.addUpdateVisitCountJob({ id, type: visitType });
|
||||
}
|
||||
});
|
||||
EventBus.on('updatePostState', ({ id }) => {
|
||||
console.log('updatePostState');
|
||||
this.addUpdatePostState({ id });
|
||||
});
|
||||
}
|
||||
async addUpdateVisitCountJob(data: updateVisitCountJobData) {
|
||||
this.logger.log(`update post view count ${data.id}`);
|
||||
|
@ -23,4 +31,10 @@ export class PostQueueService implements OnModuleInit {
|
|||
debounce: { id: data.id },
|
||||
});
|
||||
}
|
||||
async addUpdatePostState(data: updatePostStateJobData) {
|
||||
this.logger.log(`update post state ${data.id}`);
|
||||
await this.generalQueue.add(QueueJobType.UPDATE_POST_STATE, data, {
|
||||
debounce: { id: data.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { db, VisitType } from '@nice/common';
|
||||
import { db, PostState, PostType, VisitType } from '@nice/common';
|
||||
export async function updatePostViewCount(id: string, type: VisitType) {
|
||||
const totalViews = await db.visit.aggregate({
|
||||
_sum: {
|
||||
|
@ -29,22 +29,3 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
|||
});
|
||||
}
|
||||
}
|
||||
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
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -3,8 +3,12 @@ export enum QueueJobType {
|
|||
UPDATE_STATS = 'update_stats',
|
||||
FILE_PROCESS = 'file_process',
|
||||
UPDATE_POST_VISIT_COUNT = 'updatePostVisitCount',
|
||||
UPDATE_POST_STATE = 'updatePostState',
|
||||
}
|
||||
export type updateVisitCountJobData = {
|
||||
id: string;
|
||||
type: VisitType;
|
||||
};
|
||||
export type updatePostStateJobData = {
|
||||
id: string;
|
||||
};
|
||||
|
|
|
@ -41,6 +41,9 @@ export default async function processJob(job: Job<any, any, QueueJobType>) {
|
|||
if (job.name === QueueJobType.UPDATE_POST_VISIT_COUNT) {
|
||||
await updatePostViewCount(job.data.id, job.data.type);
|
||||
}
|
||||
if (job.name === QueueJobType.UPDATE_POST_STATE) {
|
||||
await updatePostViewCount(job.data.id, job.data.type);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`Error processing stats update job: ${error.message}`,
|
||||
|
|
|
@ -18,6 +18,9 @@ type Events = {
|
|||
objectType: ObjectType;
|
||||
visitType: VisitType;
|
||||
};
|
||||
updatePostState: {
|
||||
id: string;
|
||||
};
|
||||
onMessageCreated: { data: Partial<MessageDto> };
|
||||
dataChanged: { type: string; operation: CrudOperation; data: any };
|
||||
};
|
||||
|
|
|
@ -1,69 +1,48 @@
|
|||
import { createContext, useContext, ReactNode, useEffect } from "react";
|
||||
import { useForm, FormProvider, SubmitHandler } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { createContext, useContext, ReactNode } from "react";
|
||||
import { Form, FormInstance } from "antd";
|
||||
import { api, usePost } from "@nice/client";
|
||||
// import { PostDto, PostLevel, PostStatus } from "@nice/common";
|
||||
// import { api, usePost } from "@nice/client";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Post, PostType } from "@nice/common";
|
||||
// 定义帖子表单验证 Schema
|
||||
import { PostState, PostType } from "@nice/common";
|
||||
|
||||
const letterSchema = z.object({
|
||||
title: z.string().min(1, "标题不能为空"),
|
||||
content: z.string().min(1, "内容不能为空"),
|
||||
resources: z.array(z.string()).nullish(),
|
||||
isPublic: z.boolean().nullish(),
|
||||
signature: z.string().nullish(),
|
||||
meta: z
|
||||
.object({
|
||||
tags: z.array(z.string()).default([]),
|
||||
signature: z.string().nullish(),
|
||||
})
|
||||
.default({
|
||||
tags: [],
|
||||
signature: null,
|
||||
}),
|
||||
});
|
||||
// 定义课程表单验证 Schema
|
||||
|
||||
export type LetterFormData = z.infer<typeof letterSchema>;
|
||||
export interface LetterFormData {
|
||||
title: string;
|
||||
content: string;
|
||||
resources?: string[];
|
||||
isPublic?: boolean;
|
||||
signature?: string;
|
||||
meta: {
|
||||
tags: string[];
|
||||
signature?: string;
|
||||
};
|
||||
}
|
||||
interface LetterEditorContextType {
|
||||
onSubmit: SubmitHandler<LetterFormData>;
|
||||
onSubmit: (values: LetterFormData) => Promise<void>;
|
||||
receiverId?: string;
|
||||
termId?: string;
|
||||
part?: string;
|
||||
// course?: PostDto;
|
||||
form: FormInstance<LetterFormData>;
|
||||
}
|
||||
|
||||
interface LetterFormProviderProps {
|
||||
children: ReactNode;
|
||||
receiverId?: string;
|
||||
termId?: string;
|
||||
part?: string;
|
||||
}
|
||||
|
||||
const LetterEditorContext = createContext<LetterEditorContextType | null>(null);
|
||||
|
||||
export function LetterFormProvider({
|
||||
children,
|
||||
receiverId,
|
||||
termId,
|
||||
// editId,
|
||||
}: LetterFormProviderProps) {
|
||||
const { create } = usePost();
|
||||
const navigate = useNavigate();
|
||||
const methods = useForm<LetterFormData>({
|
||||
resolver: zodResolver(letterSchema),
|
||||
defaultValues: {
|
||||
resources: [],
|
||||
meta: {
|
||||
tags: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const onSubmit: SubmitHandler<LetterFormData> = async (
|
||||
data: LetterFormData
|
||||
) => {
|
||||
const [form] = Form.useForm<LetterFormData>();
|
||||
|
||||
const onSubmit = async (data: LetterFormData) => {
|
||||
try {
|
||||
console.log("data", data);
|
||||
const result = await create.mutateAsync({
|
||||
data: {
|
||||
type: PostType.POST,
|
||||
|
@ -73,6 +52,8 @@ export function LetterFormProvider({
|
|||
id,
|
||||
})),
|
||||
},
|
||||
state: PostState.PENDING,
|
||||
isPublic: data?.isPublic,
|
||||
...data,
|
||||
resources: data.resources?.length
|
||||
? {
|
||||
|
@ -83,23 +64,28 @@ export function LetterFormProvider({
|
|||
: undefined,
|
||||
},
|
||||
});
|
||||
navigate(`/course/${result.id}/detail`, { replace: true });
|
||||
navigate(`/${result.id}/detail`, { replace: true });
|
||||
toast.success("发送成功!");
|
||||
|
||||
methods.reset(data);
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
console.error("Error submitting form:", error);
|
||||
toast.error("操作失败,请重试!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LetterEditorContext.Provider
|
||||
value={{
|
||||
onSubmit,
|
||||
receiverId,
|
||||
termId,
|
||||
form,
|
||||
}}>
|
||||
<FormProvider {...methods}>{children}</FormProvider>
|
||||
<Form<LetterFormData>
|
||||
form={form}
|
||||
initialValues={{ meta: { tags: [] } }}>
|
||||
{children}
|
||||
</Form>
|
||||
</LetterEditorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,29 +1,18 @@
|
|||
import { useFormContext } from "react-hook-form";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
LetterFormData,
|
||||
useLetterEditor,
|
||||
} from "../context/LetterEditorContext";
|
||||
import { FormQuillInput } from "@web/src/components/common/form/FormQuillInput";
|
||||
import { Form, Input, Button, Checkbox, Select } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useLetterEditor } from "../context/LetterEditorContext";
|
||||
import { api } from "@nice/client";
|
||||
import {
|
||||
UserIcon,
|
||||
FolderIcon,
|
||||
HashtagIcon,
|
||||
DocumentTextIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
UserOutlined,
|
||||
FolderOutlined,
|
||||
TagOutlined,
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import FileUploader from "@web/src/components/common/uploader/FileUploader";
|
||||
import { FormTags } from "@web/src/components/common/form/FormTags";
|
||||
import { FormSignature } from "@web/src/components/common/form/FormSignature";
|
||||
import { FormCheckbox } from "@web/src/components/common/form/FormCheckbox";
|
||||
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
|
||||
|
||||
export function LetterBasicForm() {
|
||||
const {
|
||||
handleSubmit,
|
||||
getValues,
|
||||
formState: { errors },
|
||||
} = useFormContext<LetterFormData>();
|
||||
const { onSubmit, receiverId, termId } = useLetterEditor();
|
||||
const { onSubmit, receiverId, termId, form } = useLetterEditor();
|
||||
const { data: receiver } = api.staff.findFirst.useQuery(
|
||||
{
|
||||
where: {
|
||||
|
@ -41,134 +30,137 @@ export function LetterBasicForm() {
|
|||
{ enabled: !!termId }
|
||||
);
|
||||
|
||||
const formControls = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: i * 0.2,
|
||||
duration: 0.5,
|
||||
},
|
||||
}),
|
||||
const handleFinish = async (values: any) => {
|
||||
await onSubmit(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.form
|
||||
className="w-full space-y-6 p-8 "
|
||||
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"
|
||||
animate="visible"
|
||||
variants={formControls}
|
||||
className="flex items-center text-lg font-semibold text-[#00308F]">
|
||||
<UserIcon className="w-5 h-5 mr-2 text-[#00308F]" />
|
||||
<div>收件人:{receiver?.showname}</div>
|
||||
</motion.div>
|
||||
<div className="w-full space-y-6 p-8">
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleFinish}
|
||||
initialValues={{ meta: { tags: [] }, isPublic: true }}>
|
||||
{/* 收件人和板块信息行 */}
|
||||
<div className="flex justify-start items-center gap-8 ">
|
||||
<div className="flex items-center font-semibold text-[#00308F]">
|
||||
<UserOutlined className="w-5 h-5 mr-2 text-[#00308F]" />
|
||||
<div>收件人:{receiver?.showname}</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
custom={1}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={formControls}
|
||||
className="flex items-center text-lg font-semibold text-[#00308F]">
|
||||
<FolderIcon className="w-5 h-5 mr-2 text-[#00308F]" />
|
||||
<div>板块:{term?.name}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
{/* 主题输入框 */}
|
||||
<motion.div
|
||||
custom={2}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={formControls}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<HashtagIcon className="w-5 h-5 inline mr-2 text-[#00308F]" />
|
||||
主题
|
||||
</label>
|
||||
{/* <FormInput
|
||||
maxLength={20}
|
||||
name="title"
|
||||
placeholder="请输入主题"
|
||||
/> */}
|
||||
</motion.div>
|
||||
{/* 标签输入 */}
|
||||
<motion.div
|
||||
custom={4}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={formControls}
|
||||
className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<HashtagIcon className="w-5 h-5 inline mr-2 text-[#00308F]" />
|
||||
标签
|
||||
</label>
|
||||
<FormTags
|
||||
name="meta.tags"
|
||||
placeholder="输入标签后按回车添加"
|
||||
maxTags={20}
|
||||
/>
|
||||
</motion.div>
|
||||
{/* 内容输入框 */}
|
||||
<motion.div
|
||||
custom={3}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={formControls}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<DocumentTextIcon className="w-5 h-5 inline mr-2 text-[#00308F]" />
|
||||
内容
|
||||
</label>
|
||||
<FormQuillInput
|
||||
maxLength={400}
|
||||
name="content"
|
||||
placeholder="请输入内容"
|
||||
/>
|
||||
</motion.div>
|
||||
<FileUploader></FileUploader>
|
||||
<motion.div className="flex items-center justify-end gap-8 border-t border-gray-100 pt-2">
|
||||
<motion.div
|
||||
custom={3}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={formControls}
|
||||
className="flex justify-end">
|
||||
<FormCheckbox
|
||||
<div className="flex items-center font-semibold text-[#00308F]">
|
||||
<FolderOutlined className="w-5 h-5 mr-2 text-[#00308F]" />
|
||||
<div>板块:{term?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主题输入框 */}
|
||||
<div className="space-y-2 mt-4">
|
||||
<Form.Item
|
||||
required={false} //不显示星号
|
||||
label={
|
||||
<span className="block mb-1">
|
||||
<TagOutlined className="w-5 h-5 inline mr-2 text-[#00308F]" />
|
||||
标题
|
||||
<span className="text-gray-400">(必选)</span>
|
||||
</span>
|
||||
}
|
||||
name="title"
|
||||
rules={[{ required: true, message: "请输入标题" }]}
|
||||
labelCol={{ span: 24 }}
|
||||
wrapperCol={{ span: 24 }}>
|
||||
<Input maxLength={20} placeholder="请输入标题" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* 标签输入 */}
|
||||
<div className="space-y-2">
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="block mb-1">
|
||||
<TagOutlined className="w-5 h-5 inline mr-2 text-[#00308F]" />
|
||||
标签
|
||||
</span>
|
||||
}
|
||||
name={["meta", "tags"]}
|
||||
labelCol={{ span: 24 }}
|
||||
wrapperCol={{ span: 24 }}>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder="输入标签后按回车添加"
|
||||
value={form.getFieldValue(["meta", "tags"]) || []}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue(["meta", "tags"], value);
|
||||
}}
|
||||
tokenSeparators={[",", " "]}
|
||||
className="w-full"
|
||||
dropdownStyle={{ display: "none" }}
|
||||
style={{
|
||||
backgroundColor: "#f0f4ff",
|
||||
borderColor: "#00308F",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* 内容输入框 */}
|
||||
<div className="space-y-2">
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="block mb-1">
|
||||
<FileTextOutlined className="w-5 h-5 inline mr-2 text-[#00308F]" />
|
||||
内容
|
||||
<span className="text-gray-400">(必选)</span>
|
||||
</span>
|
||||
}
|
||||
name="content"
|
||||
rules={[{ required: true, message: "请输入内容" }]}
|
||||
required={false} //不显示星号
|
||||
labelCol={{ span: 24 }}
|
||||
wrapperCol={{ span: 24 }}>
|
||||
<div className="relative rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<QuillEditor
|
||||
maxLength={400}
|
||||
placeholder="请输入内容"
|
||||
minRows={6}
|
||||
maxRows={12}
|
||||
onChange={(content) =>
|
||||
form.setFieldValue("content", content)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* <FileUploader /> */}
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-end gap-4 border-t border-gray-100 pt-4">
|
||||
<Form.Item
|
||||
name="isPublic"
|
||||
label="是否公开"
|
||||
defaultChecked
|
||||
/>
|
||||
</motion.div>
|
||||
{/*
|
||||
<motion.div
|
||||
custom={5}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={formControls}
|
||||
className="flex justify-end">
|
||||
<FormSignature
|
||||
name="meta.signature"
|
||||
width="w-32"
|
||||
placeholder="添加个性签名"
|
||||
maxLength={20}
|
||||
viewMode={false}
|
||||
/>
|
||||
</motion.div> */}
|
||||
valuePropName="checked"
|
||||
className="mb-0"
|
||||
initialValue={true}>
|
||||
<Checkbox>是否公开</Checkbox>
|
||||
</Form.Item>
|
||||
|
||||
<motion.button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
className="px-4 py-2 bg-[#00308F] hover:bg-[#041E42] text-white font-bold rounded-lg
|
||||
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>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => form.submit()}
|
||||
className="bg-[#00308F] hover:bg-[#041E42] w-full sm:w-auto"
|
||||
style={{
|
||||
transform: "scale(1)",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "scale(1.02)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "scale(1)";
|
||||
}}>
|
||||
提交
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,17 +3,20 @@ 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 "antd";
|
||||
import { useVisitor } from "@nice/client";
|
||||
import { useContext } from "react";
|
||||
import { PostDetailContext } from "./context/PostDetailContext";
|
||||
import { Avatar } from "@web/src/components/common/element/Avatar";
|
||||
|
||||
|
||||
export default function PostCommentCard({
|
||||
post,
|
||||
index,
|
||||
isReceiverComment
|
||||
}: {
|
||||
post: PostDto;
|
||||
index: number;
|
||||
isReceiverComment: boolean;
|
||||
}) {
|
||||
const { user } = useContext(PostDetailContext);
|
||||
const { like } = useVisitor();
|
||||
|
@ -29,7 +32,7 @@ export default function PostCommentCard({
|
|||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to like post:', error);
|
||||
console.error("Failed to like post:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -41,14 +44,20 @@ export default function PostCommentCard({
|
|||
<div className="flex-shrink-0">
|
||||
<Avatar
|
||||
src={post.author?.avatar}
|
||||
name={post.author?.showname || "匿名用户"}
|
||||
size={40}
|
||||
/>
|
||||
>
|
||||
{!post.author?.avatar && (post.author?.showname || "匿名用户")}
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="flex items-center space-x-2"
|
||||
style={{ height: 40 }}>
|
||||
{isReceiverComment && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
|
||||
反馈解答
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium text-slate-900">
|
||||
{post.author?.showname || "匿名用户"}
|
||||
</span>
|
||||
|
|
|
@ -54,7 +54,7 @@ export default function PostCommentEditor() {
|
|||
placeholder="写下你的回复..."
|
||||
className="bg-transparent"
|
||||
theme="snow"
|
||||
minRows={3}
|
||||
minRows={6}
|
||||
maxRows={12}
|
||||
modules={{
|
||||
toolbar: [
|
||||
|
|
|
@ -13,15 +13,30 @@ import PostCommentCard from "./PostCommentCard";
|
|||
import { useInView } from "react-intersection-observer";
|
||||
import { LoadingCard } from "@web/src/components/models/post/detail/LoadingCard";
|
||||
|
||||
export default function PostCommentList() {
|
||||
export default function PostCommentList({
|
||||
official = true,
|
||||
}: {
|
||||
official?: boolean;
|
||||
}) {
|
||||
const { post } = useContext(PostDetailContext);
|
||||
const { ref: loadMoreRef, inView } = useInView();
|
||||
const { postParams } = useVisitor();
|
||||
const receiverIds = useMemo(() => {
|
||||
return (
|
||||
post?.receivers?.map((receiver) => receiver.id).filter(Boolean) ||
|
||||
[]
|
||||
);
|
||||
}, [post]);
|
||||
const params: Prisma.PostFindManyArgs = useMemo(() => {
|
||||
return {
|
||||
where: {
|
||||
parentId: post?.id,
|
||||
type: PostType.POST_COMMENT,
|
||||
authorId: official
|
||||
? { in: receiverIds }
|
||||
: {
|
||||
notIn: receiverIds,
|
||||
},
|
||||
},
|
||||
select: postDetailSelect,
|
||||
orderBy: [
|
||||
|
@ -91,7 +106,13 @@ export default function PostCommentList() {
|
|||
duration: 0.2,
|
||||
delay: index * 0.05,
|
||||
}}>
|
||||
<PostCommentCard post={comment} index={index} />
|
||||
<PostCommentCard
|
||||
post={comment}
|
||||
index={index}
|
||||
isReceiverComment={receiverIds.includes(
|
||||
comment.authorId
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
@ -112,7 +133,7 @@ export default function PostCommentList() {
|
|||
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"
|
||||
|
|
|
@ -210,7 +210,7 @@ export default function PostHeader() {
|
|||
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} 评论
|
||||
{post?.commentsCount || 0} 回复
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
import { api, usePost } from "@nice/client";
|
||||
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, useEffect, useState } from "react";
|
||||
import { PostParams } from "@nice/client/src/singleton/DataHolder";
|
||||
|
||||
interface PostDetailContextType {
|
||||
editId?: string; // 添加 editId
|
||||
editId?: string;
|
||||
post?: PostDto;
|
||||
isLoading?: boolean;
|
||||
user?: UserProfile;
|
||||
}
|
||||
interface PostFormProviderProps {
|
||||
children: ReactNode;
|
||||
editId?: string; // 添加 editId 参数
|
||||
editId?: string;
|
||||
}
|
||||
export const PostDetailContext = createContext<PostDetailContextType | null>(
|
||||
null
|
||||
|
@ -21,19 +22,27 @@ export function PostDetailProvider({
|
|||
editId,
|
||||
}: PostFormProviderProps) {
|
||||
const { user } = useAuth();
|
||||
const postParams = PostParams.getInstance();
|
||||
const queryParams = {
|
||||
where: { id: editId },
|
||||
select: postDetailSelect,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editId) {
|
||||
postParams.addDetailItem(queryParams);
|
||||
}
|
||||
return () => {
|
||||
if (editId) {
|
||||
postParams.removeDetailItem(queryParams);
|
||||
}
|
||||
};
|
||||
}, [editId]);
|
||||
|
||||
const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = (
|
||||
api.post.findFirst as any
|
||||
).useQuery(
|
||||
{
|
||||
where: { id: editId },
|
||||
select: postDetailSelect,
|
||||
},
|
||||
{ enabled: Boolean(editId) }
|
||||
);
|
||||
// const {}:{} =(
|
||||
// api.post.fin as any
|
||||
// )
|
||||
|
||||
).useQuery(queryParams, { enabled: Boolean(editId) });
|
||||
|
||||
return (
|
||||
<PostDetailContext.Provider
|
||||
value={{
|
||||
|
|
|
@ -22,21 +22,17 @@ export function useVisitor() {
|
|||
//在请求发送前执行本地数据预更新
|
||||
onMutate: async (variables: any) => {
|
||||
const previousDataList: any[] = [];
|
||||
// 动态生成参数列表,包括星标和其他参数
|
||||
const previousDetailDataList: any[] = [];
|
||||
|
||||
// 处理列表数据
|
||||
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,
|
||||
|
@ -58,7 +54,21 @@ export function useVisitor() {
|
|||
);
|
||||
}
|
||||
|
||||
return { previousDataList };
|
||||
// 处理详情数据
|
||||
const detailParamsList = postParams.getDetailItems();
|
||||
for (const params of detailParamsList) {
|
||||
await utils.post.findFirst.cancel();
|
||||
const previousDetailData = utils.post.findFirst.getData(params);
|
||||
previousDetailDataList.push(previousDetailData);
|
||||
utils.post.findFirst.setData(params, (oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.id === variables?.postId
|
||||
? updateFn(oldData, variables)
|
||||
: oldData;
|
||||
});
|
||||
}
|
||||
|
||||
return { previousDataList, previousDetailDataList };
|
||||
},
|
||||
// 错误处理:数据回滚
|
||||
onError: (_err: any, _variables: any, context: any) => {
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
export class PostParams {
|
||||
private static instance: PostParams; // 静态私有变量,用于存储单例实例
|
||||
private postParams: Array<object>; // 私有数组属性,用于存储对象
|
||||
private static instance: PostParams;
|
||||
private postParams: Array<object>;
|
||||
private postDetailParams: Array<object>;
|
||||
|
||||
private constructor() {
|
||||
this.postParams = []; // 初始化空数组
|
||||
this.postParams = [];
|
||||
this.postDetailParams = [];
|
||||
}
|
||||
|
||||
public static getInstance(): PostParams {
|
||||
|
@ -14,7 +16,6 @@ export class PostParams {
|
|||
}
|
||||
|
||||
public addItem(item: object): void {
|
||||
// 使用更可靠的方式比较查询参数
|
||||
const isDuplicate = this.postParams.some((existingItem: any) => {
|
||||
if (item && existingItem) {
|
||||
const itemWhere = (item as any).where;
|
||||
|
@ -32,8 +33,22 @@ export class PostParams {
|
|||
}
|
||||
}
|
||||
|
||||
public addDetailItem(item: object): void {
|
||||
const isDuplicate = this.postDetailParams.some((existingItem: any) => {
|
||||
if (item && existingItem) {
|
||||
const itemWhere = (item as any).where;
|
||||
const existingWhere = existingItem.where;
|
||||
return itemWhere?.id === existingWhere?.id;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!isDuplicate) {
|
||||
this.postDetailParams.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
public removeItem(item: object): void {
|
||||
// 使用相同的比较逻辑移除项
|
||||
this.postParams = this.postParams.filter((existingItem: any) => {
|
||||
if (item && existingItem) {
|
||||
const itemWhere = (item as any).where;
|
||||
|
@ -47,7 +62,24 @@ export class PostParams {
|
|||
});
|
||||
}
|
||||
|
||||
public removeDetailItem(item: object): void {
|
||||
this.postDetailParams = this.postDetailParams.filter(
|
||||
(existingItem: any) => {
|
||||
if (item && existingItem) {
|
||||
const itemWhere = (item as any).where;
|
||||
const existingWhere = existingItem.where;
|
||||
return !(itemWhere?.id === existingWhere?.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public getItems(): Array<object> {
|
||||
return [...this.postParams];
|
||||
}
|
||||
|
||||
public getDetailItems(): Array<object> {
|
||||
return [...this.postDetailParams];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -190,6 +190,7 @@ model Post {
|
|||
state String? // 状态 : 未读、处理中、已回答
|
||||
title String? // 帖子标题,可为空
|
||||
content String? // 帖子内容,可为空
|
||||
|
||||
domainId String? @map("domain_id")
|
||||
term Term? @relation(fields: [termId], references: [id])
|
||||
termId String? @map("term_id")
|
||||
|
@ -209,7 +210,7 @@ model Post {
|
|||
parent Post? @relation("PostChildren", fields: [parentId], references: [id]) // 父级帖子,关联 Post 模型
|
||||
children Post[] @relation("PostChildren") // 子级帖子列表,关联 Post 模型
|
||||
resources Resource[] // 附件列表
|
||||
isPublic Boolean? @default(false) @map("is_public")
|
||||
isPublic Boolean? @default(true) @map("is_public")
|
||||
meta Json? // 签名 和 IP 和 tags
|
||||
|
||||
// 复合索引
|
||||
|
|
|
@ -189,3 +189,9 @@ export const LessonTypeLabel = {
|
|||
[LessonType.QUIZ]: "测验",
|
||||
[LessonType.ASSIGNMENT]: "作业",
|
||||
};
|
||||
|
||||
export enum PostState {
|
||||
PENDING = "pending",
|
||||
PROCESSING = "processing",
|
||||
COMPLETED = "completed",
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ export const postDetailSelect: Prisma.PostSelect = {
|
|||
content: true,
|
||||
views: true,
|
||||
likes: true,
|
||||
isPublic: true,
|
||||
resources: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
|
|
Loading…
Reference in New Issue