This commit is contained in:
longdayi 2025-01-25 21:22:30 +08:00
commit 700b3eb2c7
10 changed files with 217 additions and 122 deletions

View File

@ -24,15 +24,14 @@ interface LetterCardProps {
export function LetterCard({ letter }: LetterCardProps) { export function LetterCard({ letter }: LetterCardProps) {
return ( return (
<div className="w-full border-2 p-4 bg-white rounded-xl transition-all duration-300 ease-in-out group"> <div className="w-full p-4 bg-white transition-all duration-300 ease-in-out group">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* Title & Priority */} {/* Title & Priority */}
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<Title level={4} className="!mb-0 flex-1"> <Title level={4} className="!mb-0 flex-1">
<a <a
href={`/letters/${letter.id}`} href={`/${letter.id}/detail`}
target="_blank" target="_blank"
className="text-primary transition-all duration-300 relative 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 before:absolute before:bottom-0 before:left-0 before:w-0 before:h-[2px] before:bg-primary-600

View File

@ -47,7 +47,7 @@ export default function PostCommentCard({
{isReceiverComment && ( {isReceiverComment && (
<div className=" "> <div className=" ">
<span className=" px-2 text-sm rounded-full bg-blue-100 text-blue-800"> <span className=" px-2 text-sm rounded-full bg-blue-100 text-blue-800">
</span> </span>
</div> </div>
)} )}

View File

@ -150,12 +150,6 @@ export default function PostCommentList() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="text-center py-12 text-slate-500"> className="text-center py-12 text-slate-500">
<Button
onClick={() => {
console.log(receiverIds);
}}>
123
</Button>
</motion.div> </motion.div>
); );
} }

View File

@ -1,4 +1,5 @@
import { useContext } from "react"; import { useContext } from "react";
import { useState, useRef, useEffect } from "react";
import { PostDetailContext } from "../context/PostDetailContext"; import { PostDetailContext } from "../context/PostDetailContext";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
@ -7,29 +8,45 @@ import { StatsSection } from "./StatsSection";
import PostResources from "../PostResources"; import PostResources from "../PostResources";
export default function Content() { export default function Content() {
const { post, user } = useContext(PostDetailContext); 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 ( return (
<motion.div <div className="relative bg-white rounded-b-xl p-6 pt-2 shadow-lg border border-[#97A9C4]/30">
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">
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.6 }} transition={{ delay: 0.6 }}
className=" text-secondary-700"> className="text-secondary-700">
<div <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={{ dangerouslySetInnerHTML={{
__html: post?.content || "", __html: post?.content || "",
}} }}
/> />
<PostResources post={post}></PostResources> {shouldCollapse && (
{/* <div>{post.resources?.map((resource) => {})}</div> */} <button
onClick={() => setIsExpanded(!isExpanded)}
className="mt-2 text-blue-500 hover:text-blue-700">
{isExpanded ? "Collapse" : "Expand"}
</button>
)}
<PostResources post={post} />
</motion.div> </motion.div>
{/* Stats Section */} {/* Stats Section */}
<StatsSection></StatsSection> <StatsSection />
</motion.div> </div>
); );
} }

View File

@ -2,6 +2,8 @@ import { useContext } from "react";
import { PostDetailContext } from "../context/PostDetailContext"; import { PostDetailContext } from "../context/PostDetailContext";
import { Space, Typography } from "antd"; import { Space, Typography } from "antd";
import { PostBadge } from "../badge/PostBadge"; import { PostBadge } from "../badge/PostBadge";
import { MailOutlined, SendOutlined } from "@ant-design/icons";
import { import {
CalendarOutlined, CalendarOutlined,
ClockCircleOutlined, ClockCircleOutlined,
@ -10,25 +12,26 @@ import {
UserOutlined, UserOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { CornerBadge } from "../badge/CornerBadeg";
const { Title, Paragraph, Text } = Typography; const { Title, Paragraph, Text } = Typography;
export default function Header() { export default function Header() {
const { post, user } = useContext(PostDetailContext); const { post, user } = useContext(PostDetailContext);
return ( return (
<header className=" rounded-t-xl bg-gradient-to-r from-primary to-primary-400 text-white p-6"> <header className="rounded-t-xl bg-gradient-to-r from-primary to-primary-400 text-white p-6 relative">
<div className="flex flex-col space-y-6"> {/* 右上角标签 */}
{/* <CornerBadge type="state" value={post?.state}></CornerBadge> */}
<div className="flex flex-col space-b-1">
{/* 主标题 */} {/* 主标题 */}
<div> <div>
<h1 className="text-3xl font-bold tracking-wider flex items-center gap-2"> <h1 className="text-3xl font-bold tracking-wider flex items-center gap-2">
{post?.title} {post?.title}
<PostBadge type="category" value={post?.term?.name} />
<PostBadge type="state" value={post?.state} />
</h1> </h1>
</div> </div>
<div className="space-y-4"> <div className="space-y-1">
{/* 收件人信息行 */} {/* 收件人信息行 */}
<Space> <Space className="mr-4">
<UserOutlined className="text-white" /> <MailOutlined className="text-white" />
<span className="text-white"></span> <span className="text-white"></span>
{post?.receivers?.map((receiver, index) => ( {post?.receivers?.map((receiver, index) => (
@ -40,59 +43,66 @@ export default function Header() {
</Text> </Text>
))} ))}
</Space> </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 */} {/* First Row - Basic Info */}
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
{/* Author Info Badge */} {/* 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> </div>
{/* Second Row - Term and Tags */} {/* Second Row - Term and Tags */}
{post?.meta?.tags?.length > 0 && ( {post?.meta?.tags?.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{/* Tags Badges */} {/* 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.length > 0 &&
post.meta.tags.map((tag, index) => ( post.meta.tags.map((tag, index) => (
<Space key={index}> <Space key={index}>
<PostBadge <PostBadge
type="tag" type="tag"
value={`#${tag}`}></PostBadge> value={`${tag}`}></PostBadge>
</Space> </Space>
))} ))}
</div> </div>

View File

@ -1,32 +1,39 @@
import React, { useContext, useMemo } from "react"; import React, { useContext, useMemo } from "react";
import { Image, Button } from "antd"; import { Image, Button } from "antd";
import { DownloadOutlined } from "@ant-design/icons"; import { DownloadOutlined, PaperClipOutlined } from "@ant-design/icons";
import { PostDetailContext } from "./context/PostDetailContext"; import { PostDetailContext } from "./context/PostDetailContext";
import { env } from "@web/src/env"; import { env } from "@web/src/env";
import dayjs from "dayjs"; import { PostDto } from "@nice/common";
import { PostDto } from "packages/common/dist";
export default function PostResources({ post }: { post: PostDto }) { export default function PostResources({ post }: { post: PostDto }) {
const { user } = useContext(PostDetailContext);
const resources = useMemo(() => { const resources = useMemo(() => {
return post?.resources?.map((resource) => ({ if (!post?.resources) return [];
url: `${env.SERVER_IP}/uploads/${resource.url}`,
title: resource.title,
}));
}, [post]);
const isImage = (url: string) => { const isImage = (url: string) => {
return /\.(png|jpg|jpeg|gif|webp)$/i.test(url); 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 ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{resources?.map((resource) => ( <div className="flex flex-col gap-4">
<div {resources
key={resource.url} ?.filter((resource) => resource.isImage)
className="flex items-center gap-4 mt-2 rounded-lg"> .map((resource) => (
{isImage(resource.url) ? ( <div key={resource.url} className="mt-2">
<>
<Image <Image
src={resource.url} src={resource.url}
alt={resource.title} alt={resource.title}
@ -35,19 +42,29 @@ export default function PostResources({ post }: { post: PostDto }) {
height={"auto"} height={"auto"}
style={{ objectFit: "cover" }} style={{ objectFit: "cover" }}
/> />
</> </div>
) : ( ))}
</div>
<div className="flex flex-wrap gap-4">
{resources
?.filter((resource) => !resource.isImage)
.map((resource) => (
<Button <Button
type="primary" key={resource.url}
icon={<DownloadOutlined />} type="text"
icon={
<PaperClipOutlined className="text-gray-500" />
}
href={resource.url} href={resource.url}
download download
className="bg-blue-600 hover:bg-blue-700"> className="flex items-center justify-start p-2 hover:bg-gray-100 transition-colors duration-200">
{resource.title || "下载"} <span className="mr-2 text-gray-600 truncate max-w-[150px]">
{resource.title || "附件"}
</span>
<DownloadOutlined className="text-blue-600" />
</Button> </Button>
)} ))}
</div> </div>
))}
</div> </div>
); );
} }

View File

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

View File

@ -1,4 +1,4 @@
import { PostState ,PostStateLabels} from "@nice/common"; import { PostState, PostStateLabels } from "@nice/common";
export function PostBadge({ export function PostBadge({
type, type,
@ -9,18 +9,18 @@ export function PostBadge({
value: string; value: string;
className?: string; className?: string;
}) { }) {
return ( return (
value && ( value && (
<span <span
className={` className={`
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${getBadgeStyle(type, value)} ${getBadgeStyle(type, value)}
transition-all duration-200 ease-in-out transform hover:scale-105
${className} ${className}
`}> `}>
{type === "state" ? PostStateLabels?.[value] : value?.toUpperCase()} {type === "state"
? PostStateLabels?.[value]
: value?.toUpperCase()}
</span> </span>
) )
); );
@ -28,23 +28,23 @@ export function PostBadge({
const BADGE_STYLES = { const BADGE_STYLES = {
priority: { priority: {
high: "bg-red-100 text-red-800", high: "bg-white text-red-800",
medium: "bg-yellow-100 text-yellow-800", medium: "bg-white text-yellow-800",
low: "bg-green-100 text-green-800", low: "bg-white text-green-800",
}, },
category: { category: {
complaint: "bg-orange-100 text-orange-800", complaint: "bg-white text-orange-800",
suggestion: "bg-blue-100 text-blue-800", suggestion: "bg-white text-blue-800",
request: "bg-purple-100 text-purple-800", request: "bg-white text-purple-800",
feedback: "bg-teal-100 text-teal-800", feedback: "bg-white text-teal-800",
}, },
state: { state: {
[PostState.PENDING]: "bg-yellow-100 text-yellow-800", [PostState.PENDING]: "bg-white text-yellow-800",
[PostState.PROCESSING]: "bg-blue-100 text-blue-800", [PostState.PROCESSING]: "bg-white text-blue-800",
[PostState.RESOLVED]: "bg-green-100 text-green-800", [PostState.RESOLVED]: "bg-white text-green-800",
}, },
tag: { tag: {
_: "bg-primary-100 text-primary-800", _: "bg-white text-primary-800",
}, },
} as const; } as const;
@ -53,10 +53,10 @@ const getBadgeStyle = (
value: string value: string
): string => { ): string => {
if (type === "tag") { if (type === "tag") {
return "bg-primary-100 text-primary"; return "bg-white text-primary";
} }
return ( return (
BADGE_STYLES[type][value as keyof (typeof BADGE_STYLES)[typeof type]] || BADGE_STYLES[type][value as keyof (typeof BADGE_STYLES)[typeof type]] ||
"bg-gray-100 text-gray-800" "bg-white text-gray-800"
); );
}; };

View File

@ -54,7 +54,6 @@ export function LetterFormProvider({
data: { data: {
...data, ...data,
type: PostType.POST, type: PostType.POST,
terms: { terms: {
connect: (terms || [])?.filter(Boolean).map((id) => ({ connect: (terms || [])?.filter(Boolean).map((id) => ({
id, id,

View File

@ -38,7 +38,7 @@ export const routes: CustomRouteObject[] = [
children: [ children: [
{ {
index: true, index: true,
element: <LetterListPage></LetterListPage> element: <LetterListPage></LetterListPage>,
}, },
{ {
path: ":id?/detail", path: ":id?/detail",
@ -53,17 +53,17 @@ export const routes: CustomRouteObject[] = [
element: <WriteLetterPage></WriteLetterPage>, element: <WriteLetterPage></WriteLetterPage>,
}, },
, { {
path: 'letter-progress', path: "letter-progress",
element: <LetterProgressPage></LetterProgressPage> element: <LetterProgressPage></LetterProgressPage>,
}, },
{ {
path: 'help', path: "help",
element: <HelpPage></HelpPage> element: <HelpPage></HelpPage>,
} },
], ],
}, },
adminRoute adminRoute,
], ],
}, },
{ {