add 2025-0124-1739

This commit is contained in:
ditiqi 2025-01-24 17:39:41 +08:00
parent d28793a15d
commit c472c217da
17 changed files with 573 additions and 237 deletions

View File

@ -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

View File

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

View File

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

View File

@ -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 {

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -64,6 +64,7 @@ export function LetterFormProvider({
: undefined,
},
});
// console.log(123);
navigate(`/${result.id}/detail`, { replace: true });
toast.success("发送成功!");
form.resetFields();

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,