diff --git a/apps/web/src/components/models/post/LetterCard.tsx b/apps/web/src/components/models/post/LetterCard.tsx index 0c9e6a9..048d307 100644 --- a/apps/web/src/components/models/post/LetterCard.tsx +++ b/apps/web/src/components/models/post/LetterCard.tsx @@ -48,7 +48,7 @@ export function LetterCard({ letter }: LetterCardProps) {
<a - href={`/letters/${letter.id}`} + href={`/${letter.id}/detail`} target="_blank" className="text-primary transition-all duration-300 relative before:absolute before:bottom-0 before:left-0 before:w-0 before:h-[2px] before:bg-primary-600 diff --git a/apps/web/src/components/models/post/detail/PostCommentCard.tsx b/apps/web/src/components/models/post/detail/PostCommentCard.tsx index e640773..c1b5366 100644 --- a/apps/web/src/components/models/post/detail/PostCommentCard.tsx +++ b/apps/web/src/components/models/post/detail/PostCommentCard.tsx @@ -47,7 +47,7 @@ export default function PostCommentCard({ {isReceiverComment && ( <div className=" "> <span className=" px-2 text-sm rounded-full bg-blue-100 text-blue-800"> - 官方回答 + 官方回复 </span> </div> )} diff --git a/apps/web/src/components/models/post/detail/PostCommentList.tsx b/apps/web/src/components/models/post/detail/PostCommentList.tsx index 2257f58..22486a7 100644 --- a/apps/web/src/components/models/post/detail/PostCommentList.tsx +++ b/apps/web/src/components/models/post/detail/PostCommentList.tsx @@ -150,12 +150,6 @@ export default function PostCommentList() { animate={{ opacity: 1, y: 0 }} className="text-center py-12 text-slate-500"> 暂无回复,来发表第一条回复吧 - <Button - onClick={() => { - console.log(receiverIds); - }}> - 123 - </Button> </motion.div> ); } diff --git a/apps/web/src/components/models/post/detail/PostHeader/Content.tsx b/apps/web/src/components/models/post/detail/PostHeader/Content.tsx index b9f3da5..0a0bd44 100644 --- a/apps/web/src/components/models/post/detail/PostHeader/Content.tsx +++ b/apps/web/src/components/models/post/detail/PostHeader/Content.tsx @@ -1,4 +1,5 @@ import { useContext } from "react"; +import { useState, useRef, useEffect } from "react"; import { PostDetailContext } from "../context/PostDetailContext"; import { motion } from "framer-motion"; @@ -7,29 +8,45 @@ import { StatsSection } from "./StatsSection"; import PostResources from "../PostResources"; export default function Content() { const { post, user } = useContext(PostDetailContext); + const [isExpanded, setIsExpanded] = useState(false); + const contentRef = useRef(null); + const [shouldCollapse, setShouldCollapse] = useState(false); + useEffect(() => { + if (contentRef.current) { + const shouldCollapse = contentRef.current.scrollHeight > 300; // 300px threshold + setShouldCollapse(shouldCollapse); + } + }, [post?.content]); return ( - <motion.div - initial={{ opacity: 0, y: -20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.5 }} - className="relative bg-white rounded-b-xl p-6 pt-2 shadow-lg border border-[#97A9C4]/30"> + <div className="relative bg-white rounded-b-xl p-6 pt-2 shadow-lg border border-[#97A9C4]/30"> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6 }} - className=" text-secondary-700"> + className="text-secondary-700"> <div - className="ql-editor p-0 space-y-1 leading-relaxed duration-300" + ref={contentRef} + className={`ql-editor p-0 space-y-1 leading-relaxed duration-300 ${ + shouldCollapse && !isExpanded + ? "max-h-[300px] overflow-hidden" + : "" + }`} dangerouslySetInnerHTML={{ __html: post?.content || "", }} /> - <PostResources post={post}></PostResources> - {/* <div>{post.resources?.map((resource) => {})}</div> */} + {shouldCollapse && ( + <button + onClick={() => setIsExpanded(!isExpanded)} + className="mt-2 text-blue-500 hover:text-blue-700"> + {isExpanded ? "Collapse" : "Expand"} + </button> + )} + <PostResources post={post} /> </motion.div> {/* Stats Section */} - <StatsSection></StatsSection> - </motion.div> + <StatsSection /> + </div> ); } diff --git a/apps/web/src/components/models/post/detail/PostHeader/Header.tsx b/apps/web/src/components/models/post/detail/PostHeader/Header.tsx index 41a839b..0e2eeea 100644 --- a/apps/web/src/components/models/post/detail/PostHeader/Header.tsx +++ b/apps/web/src/components/models/post/detail/PostHeader/Header.tsx @@ -2,6 +2,8 @@ import { useContext } from "react"; import { PostDetailContext } from "../context/PostDetailContext"; import { Space, Typography } from "antd"; import { PostBadge } from "../badge/PostBadge"; +import { MailOutlined, SendOutlined } from "@ant-design/icons"; + import { CalendarOutlined, ClockCircleOutlined, @@ -10,25 +12,26 @@ import { UserOutlined, } from "@ant-design/icons"; import dayjs from "dayjs"; +import { CornerBadge } from "../badge/CornerBadeg"; const { Title, Paragraph, Text } = Typography; export default function Header() { const { post, user } = useContext(PostDetailContext); return ( - <header className=" rounded-t-xl bg-gradient-to-r from-primary to-primary-400 text-white p-6"> - <div className="flex flex-col space-y-6"> + <header className="rounded-t-xl bg-gradient-to-r from-primary to-primary-400 text-white p-6 relative"> + {/* 右上角标签 */} + {/* <CornerBadge type="state" value={post?.state}></CornerBadge> */} + + <div className="flex flex-col space-b-1"> {/* 主标题 */} <div> <h1 className="text-3xl font-bold tracking-wider flex items-center gap-2"> {post?.title} - - <PostBadge type="category" value={post?.term?.name} /> - <PostBadge type="state" value={post?.state} /> </h1> </div> - <div className="space-y-4"> + <div className="space-y-1"> {/* 收件人信息行 */} - <Space> - <UserOutlined className="text-white" /> + <Space className="mr-4"> + <MailOutlined className="text-white" /> <span className="text-white">收件人:</span> {post?.receivers?.map((receiver, index) => ( @@ -40,59 +43,66 @@ export default function Header() { </Text> ))} </Space> + <Space className="mr-4"> + <SendOutlined className="text-white" /> + <span className="text-white">发件人:</span> + <Text className="text-white" strong> + {post?.author?.showname || "匿名用户"} + </Text> + </Space> + {/* Date Info Badge */} + <Space className="mr-4"> + <CalendarOutlined className="text-white" /> + + <Text className="text-white"> + 创建于: + {dayjs(post?.createdAt).format("YYYY-MM-DD")} + </Text> + </Space> + {/* Last Updated Badge */} + <Space className="mr-4"> + <ClockCircleOutlined className="text-white" /> + <Text className="text-white"> + 更新于: + {dayjs(post?.updatedAt).format("YYYY-MM-DD")} + </Text> + </Space> + {/* Visibility Status Badge */} + <Space className="mr-4"> + {post?.isPublic ? ( + <UnlockOutlined className="text-white" /> + ) : ( + <LockOutlined className="text-white" /> + )} + <Text className="text-white"> + {post?.isPublic ? "公开" : "私信"} + </Text> + </Space> {/* First Row - Basic Info */} <div className="flex flex-wrap items-center gap-1"> {/* Author Info Badge */} - <Space> - <UserOutlined className="text-white" /> - <span className="text-white">发件人:</span> - <Text className="text-white" strong> - {post?.author?.showname || "匿名用户"} - </Text> - </Space> - <Text type="secondary">|</Text> - {/* Date Info Badge */} - <Space> - <CalendarOutlined className="text-white" /> - - <Text className="text-white"> - 创建于: - {dayjs(post?.createdAt).format("YYYY-MM-DD")} - </Text> - </Space> - <Text type="secondary">|</Text> - {/* Last Updated Badge */} - <Space> - <ClockCircleOutlined className="text-white" /> - <Text className="text-white"> - 更新于: - {dayjs(post?.updatedAt).format("YYYY-MM-DD")} - </Text> - </Space> - <Text type="secondary">|</Text> - {/* Visibility Status Badge */} - <Space> - {post?.isPublic ? ( - <UnlockOutlined className="text-white" /> - ) : ( - <LockOutlined className="text-white" /> - )} - <Text className="text-white"> - {post?.isPublic ? "公开" : "私信"} - </Text> - </Space> </div> {/* Second Row - Term and Tags */} {post?.meta?.tags?.length > 0 && ( <div className="flex flex-wrap gap-1"> {/* Tags Badges */} + + <Space> + <PostBadge type="state" value={post?.state} /> + </Space> + <Space> + <PostBadge + type="category" + value={post?.term?.name} + /> + </Space> {post.meta.tags.length > 0 && post.meta.tags.map((tag, index) => ( <Space key={index}> <PostBadge type="tag" - value={`#${tag}`}></PostBadge> + value={`${tag}`}></PostBadge> </Space> ))} </div> diff --git a/apps/web/src/components/models/post/detail/PostResources.tsx b/apps/web/src/components/models/post/detail/PostResources.tsx index 77daa43..52bc250 100644 --- a/apps/web/src/components/models/post/detail/PostResources.tsx +++ b/apps/web/src/components/models/post/detail/PostResources.tsx @@ -1,32 +1,39 @@ import React, { useContext, useMemo } from "react"; import { Image, Button } from "antd"; -import { DownloadOutlined } from "@ant-design/icons"; +import { DownloadOutlined, PaperClipOutlined } from "@ant-design/icons"; import { PostDetailContext } from "./context/PostDetailContext"; import { env } from "@web/src/env"; -import dayjs from "dayjs"; -import { PostDto } from "packages/common/dist"; +import { PostDto } from "@nice/common"; export default function PostResources({ post }: { post: PostDto }) { - const { user } = useContext(PostDetailContext); const resources = useMemo(() => { - return post?.resources?.map((resource) => ({ - url: `${env.SERVER_IP}/uploads/${resource.url}`, - title: resource.title, - })); - }, [post]); + if (!post?.resources) return []; - const isImage = (url: string) => { - return /\.(png|jpg|jpeg|gif|webp)$/i.test(url); - }; + const isImage = (url: string) => { + return /\.(png|jpg|jpeg|gif|webp)$/i.test(url); + }; + + return post.resources + .map((resource) => ({ + url: `${env.SERVER_IP}/uploads/${resource.url}`, + title: resource.title, + isImage: isImage(resource.url), + })) + .sort((a, b) => { + // 图片排在前面,非图片排在后面 + if (a.isImage && !b.isImage) return -1; + if (!a.isImage && b.isImage) return 1; + return 0; + }); + }, [post]); return ( <div className="flex flex-col gap-4"> - {resources?.map((resource) => ( - <div - key={resource.url} - className="flex items-center gap-4 mt-2 rounded-lg"> - {isImage(resource.url) ? ( - <> + <div className="flex flex-col gap-4"> + {resources + ?.filter((resource) => resource.isImage) + .map((resource) => ( + <div key={resource.url} className="mt-2"> <Image src={resource.url} alt={resource.title} @@ -35,19 +42,29 @@ export default function PostResources({ post }: { post: PostDto }) { height={"auto"} style={{ objectFit: "cover" }} /> - </> - ) : ( + </div> + ))} + </div> + <div className="flex flex-wrap gap-4"> + {resources + ?.filter((resource) => !resource.isImage) + .map((resource) => ( <Button - type="primary" - icon={<DownloadOutlined />} + key={resource.url} + type="text" + icon={ + <PaperClipOutlined className="text-gray-500" /> + } href={resource.url} download - className="bg-blue-600 hover:bg-blue-700"> - {resource.title || "下载"} + className="flex items-center justify-start p-2 hover:bg-gray-100 transition-colors duration-200"> + <span className="mr-2 text-gray-600 truncate max-w-[150px]"> + {resource.title || "附件"} + </span> + <DownloadOutlined className="text-blue-600" /> </Button> - )} - </div> - ))} + ))} + </div> </div> ); } diff --git a/apps/web/src/components/models/post/detail/badge/CornerBadeg.tsx b/apps/web/src/components/models/post/detail/badge/CornerBadeg.tsx new file mode 100644 index 0000000..42b32bf --- /dev/null +++ b/apps/web/src/components/models/post/detail/badge/CornerBadeg.tsx @@ -0,0 +1,59 @@ +import { PostState, PostStateLabels } from "@nice/common"; + +export function CornerBadge({ + type, + value, + className = "", +}: { + type: "priority" | "category" | "state" | "tag"; + value: string; + className?: string; +}) { + return ( + value && ( + <div className="absolute top-1 right-1 "> + <div + className={` px-6 ${getBadgeStyle(type, value)} ${className} py-1.5 rounded-tr-xl rounded-bl-2xl`}> + {type === "state" + ? PostStateLabels?.[value] + : value?.toUpperCase()} + </div> + </div> + ) + ); +} + +const BADGE_STYLES = { + priority: { + high: "bg-white text-red-800", + medium: "bg-white text-yellow-800", + low: "bg-white text-green-800", + }, + category: { + complaint: "bg-white text-orange-800", + suggestion: "bg-white text-blue-800", + request: "bg-white text-purple-800", + feedback: "bg-white text-teal-800", + }, + state: { + [PostState.PENDING]: "bg-white text-yellow-800", + [PostState.PROCESSING]: "bg-white text-blue-800", + [PostState.RESOLVED]: "bg-white text-green-800", + }, + tag: { + _: "bg-white text-primary-800", + }, +} as const; + +const getBadgeStyle = ( + type: keyof typeof BADGE_STYLES, + value: string +): string => { + if (type === "tag") { + return "bg-white text-primary"; + } + return ( + BADGE_STYLES[type][value as keyof (typeof BADGE_STYLES)[typeof type]] || + "bg-white text-gray-800" + ); +}; diff --git a/apps/web/src/components/models/post/detail/badge/PostBadge.tsx b/apps/web/src/components/models/post/detail/badge/PostBadge.tsx index fb9565b..1e0b2f6 100644 --- a/apps/web/src/components/models/post/detail/badge/PostBadge.tsx +++ b/apps/web/src/components/models/post/detail/badge/PostBadge.tsx @@ -1,4 +1,4 @@ -import { PostState ,PostStateLabels} from "@nice/common"; +import { PostState, PostStateLabels } from "@nice/common"; export function PostBadge({ type, @@ -9,18 +9,18 @@ export function PostBadge({ value: string; className?: string; }) { - - return ( value && ( <span className={` inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getBadgeStyle(type, value)} - transition-all duration-200 ease-in-out transform hover:scale-105 + ${className} `}> - {type === "state" ? PostStateLabels?.[value] : value?.toUpperCase()} + {type === "state" + ? PostStateLabels?.[value] + : value?.toUpperCase()} </span> ) ); @@ -28,23 +28,23 @@ export function PostBadge({ const BADGE_STYLES = { priority: { - high: "bg-red-100 text-red-800", - medium: "bg-yellow-100 text-yellow-800", - low: "bg-green-100 text-green-800", + high: "bg-white text-red-800", + medium: "bg-white text-yellow-800", + low: "bg-white text-green-800", }, category: { - complaint: "bg-orange-100 text-orange-800", - suggestion: "bg-blue-100 text-blue-800", - request: "bg-purple-100 text-purple-800", - feedback: "bg-teal-100 text-teal-800", + complaint: "bg-white text-orange-800", + suggestion: "bg-white text-blue-800", + request: "bg-white text-purple-800", + feedback: "bg-white text-teal-800", }, state: { - [PostState.PENDING]: "bg-yellow-100 text-yellow-800", - [PostState.PROCESSING]: "bg-blue-100 text-blue-800", - [PostState.RESOLVED]: "bg-green-100 text-green-800", + [PostState.PENDING]: "bg-white text-yellow-800", + [PostState.PROCESSING]: "bg-white text-blue-800", + [PostState.RESOLVED]: "bg-white text-green-800", }, tag: { - _: "bg-primary-100 text-primary-800", + _: "bg-white text-primary-800", }, } as const; @@ -53,10 +53,10 @@ const getBadgeStyle = ( value: string ): string => { if (type === "tag") { - return "bg-primary-100 text-primary"; + return "bg-white text-primary"; } return ( BADGE_STYLES[type][value as keyof (typeof BADGE_STYLES)[typeof type]] || - "bg-gray-100 text-gray-800" + "bg-white text-gray-800" ); }; diff --git a/apps/web/src/components/models/post/editor/context/LetterEditorContext.tsx b/apps/web/src/components/models/post/editor/context/LetterEditorContext.tsx index 04db8a8..7f3dc5b 100644 --- a/apps/web/src/components/models/post/editor/context/LetterEditorContext.tsx +++ b/apps/web/src/components/models/post/editor/context/LetterEditorContext.tsx @@ -53,7 +53,6 @@ export function LetterFormProvider({ data: { ...data, type: PostType.POST, - terms: { connect: (terms || [])?.filter(Boolean).map((id) => ({ id, diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 4d03c9b..a876714 100755 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -38,7 +38,7 @@ export const routes: CustomRouteObject[] = [ children: [ { index: true, - element: <LetterListPage></LetterListPage> + element: <LetterListPage></LetterListPage>, }, { path: ":id?/detail", @@ -53,17 +53,17 @@ export const routes: CustomRouteObject[] = [ element: <WriteLetterPage></WriteLetterPage>, }, - , { - path: 'letter-progress', - element: <LetterProgressPage></LetterProgressPage> + { + path: "letter-progress", + element: <LetterProgressPage></LetterProgressPage>, }, { - path: 'help', - element: <HelpPage></HelpPage> - } + path: "help", + element: <HelpPage></HelpPage>, + }, ], }, - adminRoute + adminRoute, ], }, {