add 2025-0124-1739
This commit is contained in:
parent
d28793a15d
commit
c472c217da
|
@ -569,6 +569,7 @@ export class BaseService<
|
|||
): Promise<T> {
|
||||
try {
|
||||
const result = await operationPromise;
|
||||
|
||||
return await transformFn(result);
|
||||
} catch (error) {
|
||||
throw error; // Re-throw the error to maintain existing error handling
|
||||
|
|
|
@ -70,8 +70,11 @@ export class PostRouter {
|
|||
}),
|
||||
findFirst: this.trpc.procedure
|
||||
.input(PostFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.postService.findFirst(input);
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { staff, req } = ctx;
|
||||
// 从请求中获取 IP
|
||||
const ip = getClientIp(req);
|
||||
return await this.postService.findFirst(input, staff, ip);
|
||||
}),
|
||||
deleteMany: this.trpc.protectProcedure
|
||||
.input(PostDeleteManyArgsSchema)
|
||||
|
@ -88,8 +91,10 @@ export class PostRouter {
|
|||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.findManyWithCursor(input, staff);
|
||||
const { staff, req } = ctx;
|
||||
// 从请求中获取 IP
|
||||
const ip = getClientIp(req);
|
||||
return await this.postService.findManyWithCursor(input, staff, ip);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -61,28 +61,37 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
|
|||
});
|
||||
return result;
|
||||
}
|
||||
async findFirst(args?: Prisma.PostFindFirstArgs, staff?: UserProfile) {
|
||||
async findFirst(
|
||||
args?: Prisma.PostFindFirstArgs,
|
||||
staff?: UserProfile,
|
||||
clientIp?: string,
|
||||
) {
|
||||
const transDto = await this.wrapResult(
|
||||
super.findFirst(args),
|
||||
async (result) => {
|
||||
await setPostRelation({ data: result, staff });
|
||||
await setPostRelation({ data: result, staff, clientIp });
|
||||
await this.setPerms(result, staff);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
return transDto;
|
||||
}
|
||||
async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
|
||||
async findManyWithCursor(
|
||||
args: Prisma.PostFindManyArgs,
|
||||
staff?: UserProfile,
|
||||
clientIp?: string,
|
||||
) {
|
||||
if (!args.where) args.where = {};
|
||||
args.where.OR = await this.preFilter(args.where.OR, staff);
|
||||
return this.wrapResult(super.findManyWithCursor(args), async (result) => {
|
||||
const { items } = result;
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
await setPostRelation({ data: item, staff });
|
||||
await setPostRelation({ data: item, staff, clientIp });
|
||||
await this.setPerms(item, staff);
|
||||
}),
|
||||
);
|
||||
|
||||
return { ...result, items };
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
db,
|
||||
Post,
|
||||
PostDto,
|
||||
PostState,
|
||||
PostType,
|
||||
UserProfile,
|
||||
|
@ -10,11 +11,12 @@ import {
|
|||
export async function setPostRelation(params: {
|
||||
data: Post;
|
||||
staff?: UserProfile;
|
||||
clientIp?: string;
|
||||
}) {
|
||||
const { data, staff } = params;
|
||||
const { data, staff, clientIp } = params;
|
||||
// 在函数开始时计算一次时间
|
||||
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
|
||||
const clientIp = (data?.meta as any)?.ip;
|
||||
|
||||
const commentsCount = await db.post.count({
|
||||
where: {
|
||||
parentId: data.id,
|
||||
|
@ -29,23 +31,25 @@ export async function setPostRelation(params: {
|
|||
visitorId: staff?.id,
|
||||
},
|
||||
})) > 0;
|
||||
const liked = await db.visit.count({
|
||||
where: {
|
||||
postId: data.id,
|
||||
type: VisitType?.LIKE,
|
||||
...(staff?.id
|
||||
? // 如果有 staff,查找对应的 visitorId
|
||||
{ visitorId: staff.id }
|
||||
: // 如果没有 staff,查找相同 IP 且 visitorId 为 null 且 30 分钟内的记录
|
||||
{
|
||||
visitorId: null,
|
||||
meta: { path: ['ip'], equals: clientIp },
|
||||
updatedAt: {
|
||||
gte: thirtyMinutesAgo,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const 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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
})) > 0;
|
||||
const readedCount = await db.visit.count({
|
||||
where: {
|
||||
postId: data.id,
|
||||
|
@ -61,6 +65,8 @@ export async function setPostRelation(params: {
|
|||
commentsCount,
|
||||
// trouble
|
||||
});
|
||||
// console.log('data', data);
|
||||
return data; // 明确返回修改后的数据
|
||||
}
|
||||
|
||||
export function getClientIp(req: any): string {
|
||||
|
|
|
@ -6,6 +6,7 @@ import { VisitService } from './visit.service';
|
|||
import { z, ZodType } from 'zod';
|
||||
import { getClientIp } from './utils';
|
||||
const VisitCreateArgsSchema: ZodType<Prisma.VisitCreateArgs> = z.any();
|
||||
const VisitDeleteArgsSchema: ZodType<Prisma.VisitDeleteArgs> = z.any();
|
||||
const VisitCreateManyInputSchema: ZodType<Prisma.VisitCreateManyInput> =
|
||||
z.any();
|
||||
const VisitDeleteManyArgsSchema: ZodType<Prisma.VisitDeleteManyArgs> = z.any();
|
||||
|
@ -41,8 +42,27 @@ export class VisitRouter {
|
|||
}),
|
||||
deleteMany: this.trpc.procedure
|
||||
.input(VisitDeleteManyArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.visitService.deleteMany(input);
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff, req } = ctx;
|
||||
// 从请求中获取 IP
|
||||
if (!input?.where?.visitorId) {
|
||||
const ip = getClientIp(req);
|
||||
// Create a new where clause
|
||||
const newWhere = {
|
||||
...input.where,
|
||||
meta: {
|
||||
path: ['ip'],
|
||||
equals: ip || '',
|
||||
},
|
||||
};
|
||||
|
||||
// Update the input with the new where clause
|
||||
input = {
|
||||
...input,
|
||||
where: newWhere,
|
||||
};
|
||||
}
|
||||
return await this.visitService.deleteMany(input, staff);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -123,4 +123,64 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
|
|||
|
||||
return { count: updatePromises.length }; // Return the number of updates if no new creates
|
||||
}
|
||||
async deleteMany(args: Prisma.VisitDeleteManyArgs, staff?: UserProfile) {
|
||||
// const where = Array.isArray(args.where) ? args.where : [args.where];
|
||||
// const updatePromises: any[] = [];
|
||||
// const createData: Prisma.VisitCreateManyInput[] = [];
|
||||
// super
|
||||
// await Promise.all(
|
||||
// data.map(async (item) => {
|
||||
// if (staff && !item.visitorId) item.visitorId = staff.id;
|
||||
// const { postId, messageId, visitorId } = item;
|
||||
// const existingVisit = await db.visit.findFirst({
|
||||
// where: {
|
||||
// visitorId,
|
||||
// OR: [{ postId }, { messageId }],
|
||||
// },
|
||||
// });
|
||||
|
||||
// if (existingVisit) {
|
||||
// updatePromises.push(
|
||||
// super.update({
|
||||
// where: { id: existingVisit.id },
|
||||
// data: {
|
||||
// ...item,
|
||||
// views: existingVisit.views + 1,
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
// } else {
|
||||
// createData.push(item);
|
||||
// }
|
||||
// }),
|
||||
// );
|
||||
// // Execute all updates in parallel
|
||||
// await Promise.all(updatePromises);
|
||||
// // Create new visits for those not existing
|
||||
// if (createData.length > 0) {
|
||||
// return super.createMany({
|
||||
// ...args,
|
||||
// data: createData,
|
||||
// });
|
||||
// }
|
||||
// return { count: updatePromises.length }; // Return the number of updates if no new creates
|
||||
const superDetele = super.deleteMany(args, staff);
|
||||
if (args?.where?.postId) {
|
||||
if (args.where.type === VisitType.READED) {
|
||||
EventBus.emit('updateVisitCount', {
|
||||
objectType: ObjectType.POST,
|
||||
id: args?.where?.postId as string,
|
||||
visitType: VisitType.READED,
|
||||
});
|
||||
}
|
||||
if (args.where.type === VisitType.LIKE) {
|
||||
EventBus.emit('updateVisitCount', {
|
||||
objectType: ObjectType.POST,
|
||||
id: args?.where?.postId as string,
|
||||
visitType: VisitType.LIKE,
|
||||
});
|
||||
}
|
||||
}
|
||||
return superDetele;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,13 +28,15 @@ export class PostQueueService implements OnModuleInit {
|
|||
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 },
|
||||
debounce: {
|
||||
id: `${QueueJobType.UPDATE_POST_VISIT_COUNT}_${data.type}_${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 },
|
||||
debounce: { id: `${QueueJobType.UPDATE_POST_STATE}_${data.id}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,16 +12,17 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
|||
if (type === VisitType.READED) {
|
||||
await db.post.update({
|
||||
where: {
|
||||
id,
|
||||
id: id,
|
||||
},
|
||||
data: {
|
||||
views: totalViews._sum.views || 0, // Use 0 if no visits exist
|
||||
},
|
||||
});
|
||||
} else if (type === VisitType.LIKE) {
|
||||
console.log('totalViews._sum.view', totalViews._sum.views);
|
||||
await db.post.update({
|
||||
where: {
|
||||
id,
|
||||
id: id,
|
||||
},
|
||||
data: {
|
||||
likes: totalViews._sum.views || 0, // Use 0 if no visits exist
|
||||
|
|
|
@ -64,6 +64,7 @@ export function LetterFormProvider({
|
|||
: undefined,
|
||||
},
|
||||
});
|
||||
// console.log(123);
|
||||
navigate(`/${result.id}/detail`, { replace: true });
|
||||
toast.success("发送成功!");
|
||||
form.resetFields();
|
||||
|
|
|
@ -1,30 +1,33 @@
|
|||
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 "antd";
|
||||
import { useVisitor } from "@nice/client";
|
||||
import { useContext } from "react";
|
||||
import { useContext, useState } from "react";
|
||||
import { PostDetailContext } from "./context/PostDetailContext";
|
||||
|
||||
import { LikeFilled, LikeOutlined } from "@ant-design/icons";
|
||||
|
||||
export default function PostCommentCard({
|
||||
post,
|
||||
index,
|
||||
isReceiverComment
|
||||
isReceiverComment,
|
||||
}: {
|
||||
post: PostDto;
|
||||
index: number;
|
||||
isReceiverComment: boolean;
|
||||
}) {
|
||||
const { user } = useContext(PostDetailContext);
|
||||
const { like } = useVisitor();
|
||||
const { like, unLike } = useVisitor();
|
||||
const [liked, setLiked] = useState(post?.liked || false);
|
||||
const [likeCount, setLikeCount] = useState(post?.likes || 0);
|
||||
|
||||
async function likeThisPost() {
|
||||
if (!post?.liked) {
|
||||
if (!liked) {
|
||||
try {
|
||||
await like.mutateAsync({
|
||||
setLikeCount((prev) => prev + 1);
|
||||
setLiked(true);
|
||||
like.mutateAsync({
|
||||
data: {
|
||||
visitorId: user?.id || null,
|
||||
postId: post.id,
|
||||
|
@ -33,7 +36,19 @@ export default function PostCommentCard({
|
|||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to like post:", error);
|
||||
setLikeCount((prev) => prev - 1);
|
||||
setLiked(false);
|
||||
}
|
||||
} else {
|
||||
setLikeCount((prev) => prev - 1);
|
||||
setLiked(false);
|
||||
unLike.mutateAsync({
|
||||
where: {
|
||||
visitorId: user?.id || null,
|
||||
postId: post.id,
|
||||
type: VisitType.LIKE,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
@ -43,27 +58,30 @@ export default function PostCommentCard({
|
|||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<Avatar
|
||||
className="ring-2 ring-white hover:ring-[#00538E]/90
|
||||
transition-all duration-200 ease-in-out shadow-md
|
||||
hover:shadow-lg"
|
||||
src={post.author?.avatar}
|
||||
size={40}
|
||||
>
|
||||
{!post.author?.avatar && (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>
|
||||
<span className="text-sm text-slate-500">
|
||||
{dayjs(post?.createdAt).format("YYYY-MM-DD HH:mm")}
|
||||
</span>
|
||||
{isReceiverComment && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
|
||||
官方回答
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="ql-editor text-slate-800"
|
||||
|
@ -80,17 +98,13 @@ export default function PostCommentCard({
|
|||
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>
|
||||
${
|
||||
liked
|
||||
? "bg-blue-50 text-blue-600"
|
||||
: "hover:bg-slate-50 text-slate-600"
|
||||
} transition-colors duration-200`}>
|
||||
{liked ? <LikeFilled /> : <LikeOutlined />}
|
||||
<span>{likeCount} 有帮助</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -12,12 +12,9 @@ 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";
|
||||
import { Button } from "antd";
|
||||
|
||||
export default function PostCommentList({
|
||||
official = true,
|
||||
}: {
|
||||
official?: boolean;
|
||||
}) {
|
||||
export default function PostCommentList() {
|
||||
const { post } = useContext(PostDetailContext);
|
||||
const { ref: loadMoreRef, inView } = useInView();
|
||||
const { postParams } = useVisitor();
|
||||
|
@ -27,56 +24,117 @@ export default function PostCommentList({
|
|||
[]
|
||||
);
|
||||
}, [post]);
|
||||
const params: Prisma.PostFindManyArgs = useMemo(() => {
|
||||
return {
|
||||
const officialParams: Prisma.PostFindManyArgs = useMemo(
|
||||
() => ({
|
||||
where: {
|
||||
parentId: post?.id,
|
||||
type: PostType.POST_COMMENT,
|
||||
authorId: official
|
||||
? { in: receiverIds }
|
||||
: {
|
||||
notIn: receiverIds,
|
||||
},
|
||||
authorId: { in: receiverIds },
|
||||
},
|
||||
select: postDetailSelect,
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: 3,
|
||||
};
|
||||
}, [post]);
|
||||
}),
|
||||
[post, receiverIds]
|
||||
);
|
||||
|
||||
const nonOfficialParams: Prisma.PostFindManyArgs = useMemo(
|
||||
() => ({
|
||||
where: {
|
||||
parentId: post?.id,
|
||||
type: PostType.POST_COMMENT,
|
||||
authorId: { notIn: receiverIds },
|
||||
},
|
||||
select: postDetailSelect,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: 3,
|
||||
}),
|
||||
[post, receiverIds]
|
||||
);
|
||||
|
||||
const {
|
||||
data: queryData,
|
||||
fetchNextPage,
|
||||
refetch,
|
||||
isPending,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
} = api.post.findManyWithCursor.useInfiniteQuery(params, {
|
||||
data: officialData,
|
||||
fetchNextPage: fetchNextOfficialPage,
|
||||
isFetchingNextPage: isFetchingNextOfficialPage,
|
||||
hasNextPage: hasNextOfficialPage,
|
||||
isLoading: isOfficialLoading,
|
||||
} = api.post.findManyWithCursor.useInfiniteQuery(officialParams, {
|
||||
enabled: !!post?.id,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const {
|
||||
data: nonOfficialData,
|
||||
fetchNextPage: fetchNextNonOfficialPage,
|
||||
isFetchingNextPage: isFetchingNextNonOfficialPage,
|
||||
hasNextPage: hasNextNonOfficialPage,
|
||||
isLoading: isNonOfficialLoading,
|
||||
} = api.post.findManyWithCursor.useInfiniteQuery(nonOfficialParams, {
|
||||
enabled: !!post?.id,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const officialItems = useMemo(
|
||||
() =>
|
||||
((officialData?.pages as any)?.flatMap(
|
||||
(page) => page.items
|
||||
) as any) || [],
|
||||
[officialData]
|
||||
);
|
||||
|
||||
const nonOfficialItems = useMemo(
|
||||
() =>
|
||||
(nonOfficialData?.pages as any)?.flatMap((page) => page.items) ||
|
||||
[],
|
||||
[nonOfficialData]
|
||||
);
|
||||
|
||||
const isLoading = isOfficialLoading || isNonOfficialLoading;
|
||||
useEffect(() => {
|
||||
if (post?.id) {
|
||||
postParams.addItem(params);
|
||||
postParams.addItem(officialParams);
|
||||
postParams.addItem(nonOfficialParams);
|
||||
return () => {
|
||||
postParams.removeItem(params);
|
||||
postParams.removeItem(officialParams);
|
||||
postParams.removeItem(nonOfficialParams);
|
||||
};
|
||||
}
|
||||
}, [post, params]);
|
||||
const items = useMemo<PostDto[]>(() => {
|
||||
return queryData?.pages?.flatMap((page: any) => page.items) || [];
|
||||
}, [queryData]);
|
||||
}, [post, officialParams, nonOfficialParams]);
|
||||
|
||||
const convertToPostDto = (item: any): PostDto => ({
|
||||
...item,
|
||||
createdAt: new Date(item.createdAt),
|
||||
updatedAt: new Date(item.updatedAt),
|
||||
deletedAt: item.deletedAt ? new Date(item.deletedAt) : new Date(0),
|
||||
});
|
||||
|
||||
const items = useMemo(() => {
|
||||
return [
|
||||
...officialItems.map(convertToPostDto),
|
||||
...nonOfficialItems.map(convertToPostDto),
|
||||
];
|
||||
}, [officialItems, nonOfficialItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
if (inView) {
|
||||
if (hasNextOfficialPage && !isFetchingNextOfficialPage) {
|
||||
fetchNextOfficialPage();
|
||||
} else if (
|
||||
hasNextNonOfficialPage &&
|
||||
!isFetchingNextNonOfficialPage
|
||||
) {
|
||||
fetchNextNonOfficialPage();
|
||||
}
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
}, [
|
||||
inView,
|
||||
hasNextOfficialPage,
|
||||
hasNextNonOfficialPage,
|
||||
isFetchingNextOfficialPage,
|
||||
isFetchingNextNonOfficialPage,
|
||||
fetchNextOfficialPage,
|
||||
fetchNextNonOfficialPage,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingCard />;
|
||||
|
@ -119,13 +177,14 @@ export default function PostCommentList({
|
|||
|
||||
{/* 加载更多触发器 */}
|
||||
<div ref={loadMoreRef} className="h-20">
|
||||
{isFetchingNextPage ? (
|
||||
{isFetchingNextOfficialPage || isFetchingNextNonOfficialPage ? (
|
||||
<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 && (
|
||||
!hasNextOfficialPage &&
|
||||
!hasNextNonOfficialPage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
|
|
@ -11,16 +11,34 @@ import {
|
|||
EyeIcon,
|
||||
ChatBubbleLeftIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { useVisitor } from "@nice/client";
|
||||
import { VisitType } from "@nice/common";
|
||||
import { PostState, VisitType } from "@nice/common";
|
||||
import {
|
||||
CommentOutlined,
|
||||
EyeOutlined,
|
||||
LikeFilled,
|
||||
LikeOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import { TitleSection } from "./PostHeader/TitleSection";
|
||||
import {
|
||||
AuthorBadge,
|
||||
DateBadge,
|
||||
TermBadge,
|
||||
UpdatedBadge,
|
||||
VisibilityBadge,
|
||||
} from "./PostHeader/InfoBadge";
|
||||
import { StatsSection } from "./PostHeader/StatsSection";
|
||||
|
||||
export default function PostHeader() {
|
||||
const { post, user } = useContext(PostDetailContext);
|
||||
const { like } = useVisitor();
|
||||
const { like, unLike } = useVisitor();
|
||||
|
||||
function likeThisPost() {
|
||||
if (!post?.liked) {
|
||||
post.likes += 1;
|
||||
post.liked = true;
|
||||
like.mutateAsync({
|
||||
data: {
|
||||
visitorId: user?.id || null,
|
||||
|
@ -28,6 +46,16 @@ export default function PostHeader() {
|
|||
type: VisitType.LIKE,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
post.likes -= 1;
|
||||
post.liked = false;
|
||||
unLike.mutateAsync({
|
||||
where: {
|
||||
visitorId: user?.id || null,
|
||||
postId: post.id,
|
||||
type: VisitType.LIKE,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -42,124 +70,57 @@ export default function PostHeader() {
|
|||
<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>
|
||||
|
||||
<TitleSection
|
||||
title={post?.title}
|
||||
state={post?.state as PostState}></TitleSection>
|
||||
|
||||
<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>
|
||||
<AuthorBadge
|
||||
name={
|
||||
post?.author?.showname || "匿名用户"
|
||||
}></AuthorBadge>
|
||||
|
||||
{/* 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>
|
||||
<DateBadge
|
||||
date={dayjs(post?.createdAt).format("YYYY-MM-DD")}
|
||||
label="创建于:"></DateBadge>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<UpdatedBadge
|
||||
date={dayjs(post?.updatedAt).format(
|
||||
"YYYY-MM-DD"
|
||||
)}></UpdatedBadge>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<VisibilityBadge
|
||||
isPublic={post?.isPublic}></VisibilityBadge>
|
||||
</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>
|
||||
<TermBadge term={post.term.name}></TermBadge>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{post?.meta?.tags &&
|
||||
post.meta.tags.length > 0 &&
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -176,44 +137,12 @@ export default function PostHeader() {
|
|||
</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>
|
||||
<StatsSection
|
||||
likes={post?.likes}
|
||||
views={post?.views}
|
||||
commentsCount={post?.commentsCount}
|
||||
liked={post?.liked}
|
||||
onLikeClick={likeThisPost}></StatsSection>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
UserCircleIcon,
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
LockClosedIcon,
|
||||
LockOpenIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
interface InfoBadgeProps {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function InfoBadge({ icon, text, delay = 0 }: InfoBadgeProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay }}
|
||||
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">
|
||||
{icon}
|
||||
<span className="text-[#2B4C7E]">{text}</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthorBadge({ name }: { name: string }) {
|
||||
return (
|
||||
<InfoBadge
|
||||
icon={<UserCircleIcon className="h-5 w-5 text-[#2B4C7E]" />}
|
||||
text={name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DateBadge({ date, label }: { date: string; label: string }) {
|
||||
return (
|
||||
<InfoBadge
|
||||
icon={<CalendarIcon className="h-5 w-5 text-[#2B4C7E]" />}
|
||||
text={`${label}: ${dayjs(date).format("YYYY-MM-DD")}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdatedBadge({ date }: { date: string }) {
|
||||
return (
|
||||
<InfoBadge
|
||||
icon={<ClockIcon className="h-5 w-5 text-[#2B4C7E]" />}
|
||||
text={`更新于: ${dayjs(date).format("YYYY-MM-DD")}`}
|
||||
delay={0.45}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function VisibilityBadge({ isPublic }: { isPublic: boolean }) {
|
||||
return (
|
||||
<InfoBadge
|
||||
icon={
|
||||
isPublic ? (
|
||||
<LockOpenIcon className="h-5 w-5 text-[#2B4C7E]" />
|
||||
) : (
|
||||
<LockClosedIcon className="h-5 w-5 text-[#2B4C7E]" />
|
||||
)
|
||||
}
|
||||
text={isPublic ? "公开" : "私信"}
|
||||
delay={0.5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TermBadge({ term }: { term: string }) {
|
||||
return (
|
||||
<InfoBadge
|
||||
icon={<StarIcon className="h-5 w-5 text-[#2B4C7E]" />}
|
||||
text={term}
|
||||
delay={0.55}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { LikeFilled, LikeOutlined, EyeOutlined, CommentOutlined } from "@ant-design/icons";
|
||||
|
||||
interface StatsButtonProps {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function StatsButton({ icon, text, onClick, isActive }: StatsButtonProps) {
|
||||
return (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md ${
|
||||
isActive
|
||||
? "bg-[#507AAF] text-white"
|
||||
: "bg-white text-[#2B4C7E] hover:bg-[#507AAF] hover:text-white"
|
||||
} transition-all duration-300 shadow-md border border-[#97A9C4]/30`}
|
||||
>
|
||||
{icon}
|
||||
<span className="font-medium">{text}</span>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatsSectionProps {
|
||||
likes: number;
|
||||
views: number;
|
||||
commentsCount: number;
|
||||
liked: boolean;
|
||||
onLikeClick: () => void;
|
||||
}
|
||||
|
||||
export function StatsSection({
|
||||
likes,
|
||||
views,
|
||||
commentsCount,
|
||||
liked,
|
||||
onLikeClick,
|
||||
}: StatsSectionProps) {
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<StatsButton
|
||||
icon={liked ? <LikeFilled style={{ fontSize: 18 }} /> : <LikeOutlined style={{ fontSize: 18 }} />}
|
||||
text={`${likes} 有帮助`}
|
||||
onClick={onLikeClick}
|
||||
isActive={liked}
|
||||
/>
|
||||
<StatsButton
|
||||
icon={<EyeOutlined style={{ fontSize: 18 }} />}
|
||||
text={`${views} 浏览`}
|
||||
/>
|
||||
<StatsButton
|
||||
icon={<CommentOutlined style={{ fontSize: 18 }} />}
|
||||
text={`${commentsCount} 回复`}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import { motion } from "framer-motion";
|
||||
import { Tag } from "antd";
|
||||
import { PostState } from "@nice/common";
|
||||
import {
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
interface TitleSectionProps {
|
||||
title: string;
|
||||
state: PostState;
|
||||
}
|
||||
|
||||
const stateColors = {
|
||||
[PostState.PENDING]: "orange",
|
||||
[PostState.PROCESSING]: "blue",
|
||||
[PostState.COMPLETED]: "green",
|
||||
};
|
||||
|
||||
const stateLabels = {
|
||||
[PostState.PENDING]: "待处理",
|
||||
[PostState.PROCESSING]: "处理中",
|
||||
[PostState.COMPLETED]: "已完成",
|
||||
};
|
||||
|
||||
export function TitleSection({ title, state }: TitleSectionProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="relative mb-6 flex items-center gap-4">
|
||||
{/* Decorative Line */}
|
||||
<div className="absolute -left-2 top-1/2 -translate-y-1/2 w-1 h-8 bg-[#97A9C4]" />
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl font-bold text-[#2B4C7E] pl-4 tracking-wider uppercase">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{/* State Tag */}
|
||||
{/* <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"> */}
|
||||
<Tag
|
||||
color={stateColors[state]}
|
||||
className="flex items-center gap-1.5 px-3 py-1 shadow-md rounded-md text-sm font-medium border-none">
|
||||
{state === PostState.PENDING && (
|
||||
<ExclamationCircleIcon className="h-4 w-4" />
|
||||
)}
|
||||
{state === PostState.PROCESSING && (
|
||||
<ClockIcon className="h-4 w-4" />
|
||||
)}
|
||||
{state === PostState.COMPLETED && (
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
)}
|
||||
{stateLabels[state]}
|
||||
</Tag>
|
||||
{/* </motion.div> */}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
|
@ -110,6 +110,13 @@ export function useVisitor() {
|
|||
liked: true,
|
||||
}))
|
||||
);
|
||||
const unLike = api.visitor.deleteMany.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
...item,
|
||||
likes: item.likes - 1 || 0,
|
||||
liked: false,
|
||||
}))
|
||||
);
|
||||
|
||||
const addStar = api.visitor.create.useMutation(
|
||||
createOptimisticMutation((item) => ({
|
||||
|
@ -148,5 +155,6 @@ export function useVisitor() {
|
|||
addStar,
|
||||
deleteStar,
|
||||
like,
|
||||
unLike,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ export const postDetailSelect: Prisma.PostSelect = {
|
|||
id: true,
|
||||
type: true,
|
||||
title: true,
|
||||
state: true,
|
||||
content: true,
|
||||
views: true,
|
||||
likes: true,
|
||||
|
@ -17,6 +18,7 @@ export const postDetailSelect: Prisma.PostSelect = {
|
|||
taxonomy: true,
|
||||
},
|
||||
},
|
||||
authorId: true,
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
|
|
Loading…
Reference in New Issue