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) {
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">
{/* Title & Priority */}
<div className="flex justify-between items-start">
<Title level={4} className="!mb-0 flex-1">
<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

View File

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

View File

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

View File

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

View File

@ -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,20 +43,15 @@ export default function Header() {
</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>
<Space className="mr-4">
<SendOutlined 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>
<Space className="mr-4">
<CalendarOutlined className="text-white" />
<Text className="text-white">
@ -61,18 +59,16 @@ export default function Header() {
{dayjs(post?.createdAt).format("YYYY-MM-DD")}
</Text>
</Space>
<Text type="secondary">|</Text>
{/* Last Updated Badge */}
<Space>
<Space className="mr-4">
<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>
<Space className="mr-4">
{post?.isPublic ? (
<UnlockOutlined className="text-white" />
) : (
@ -82,17 +78,31 @@ export default function Header() {
{post?.isPublic ? "公开" : "私信"}
</Text>
</Space>
{/* First Row - Basic Info */}
<div className="flex flex-wrap items-center gap-1">
{/* Author Info Badge */}
</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>

View File

@ -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);
};
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" }}
/>
</>
) : (
<Button
type="primary"
icon={<DownloadOutlined />}
href={resource.url}
download
className="bg-blue-600 hover:bg-blue-700">
{resource.title || "下载"}
</Button>
)}
</div>
))}
</div>
<div className="flex flex-wrap gap-4">
{resources
?.filter((resource) => !resource.isImage)
.map((resource) => (
<Button
key={resource.url}
type="text"
icon={
<PaperClipOutlined className="text-gray-500" />
}
href={resource.url}
download
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>
);
}

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

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

View File

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

View File

@ -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,
],
},
{