This commit is contained in:
ditiqi 2025-01-25 19:51:08 +08:00
parent 455c82911d
commit db2e3a044b
18 changed files with 329 additions and 337 deletions

View File

@ -23,12 +23,12 @@ import { UploadModule } from './upload/upload.module';
imports: [
ConfigModule.forRoot({
isGlobal: true, // 全局可用
envFilePath: '.env'
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
JwtModule.register({
global: true,
secret: env.JWT_SECRET
secret: env.JWT_SECRET,
}),
WebSocketModule,
TrpcModule,
@ -42,11 +42,13 @@ import { UploadModule } from './upload/upload.module';
MinioModule,
CollaborationModule,
RealTimeModule,
UploadModule
UploadModule,
],
providers: [
{
provide: APP_FILTER,
useClass: ExceptionsFilter,
},
],
providers: [{
provide: APP_FILTER,
useClass: ExceptionsFilter,
}],
})
export class AppModule { }
export class AppModule {}

View File

@ -8,7 +8,7 @@ async function bootstrap() {
// 启用 CORS 并允许所有来源
app.enableCors({
origin: "*",
origin: '*',
});
const wsService = app.get(WebSocketService);
await wsService.initialize(app.getHttpServer());
@ -18,6 +18,5 @@ async function bootstrap() {
const port = process.env.SERVER_PORT || 3000;
await app.listen(port);
}
bootstrap();

View File

@ -17,7 +17,7 @@ export class PostRouter {
constructor(
private readonly trpc: TrpcService,
private readonly postService: PostService,
) { }
) {}
router = this.trpc.router({
create: this.trpc.protectProcedure
.input(PostCreateArgsSchema)
@ -97,12 +97,14 @@ export class PostRouter {
return await this.postService.findManyWithCursor(input, staff, ip);
}),
findManyWithPagination: this.trpc.procedure
.input(z.object({
page: z.number(),
pageSize: z.number().optional(),
where: PostWhereInputSchema.optional(),
select: PostSelectSchema.optional()
})) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.input(
z.object({
page: z.number(),
pageSize: z.number().optional(),
where: PostWhereInputSchema.optional(),
select: PostSelectSchema.optional(),
}),
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.postService.findManyWithPagination(input);
}),

View File

@ -33,6 +33,7 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
args: Prisma.PostCreateArgs,
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
) {
console.log('params?.staff?.id', params?.staff?.id);
args.data.authorId = params?.staff?.id;
args.data.updatedAt = new Date();
// args.data.resources

View File

@ -5,13 +5,13 @@ import { TusService } from './tus.service';
import { ResourceModule } from '@server/models/resource/resource.module';
@Module({
imports: [
BullModule.registerQueue({
name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致
}),
ResourceModule
],
controllers: [UploadController],
providers: [TusService],
imports: [
BullModule.registerQueue({
name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致
}),
ResourceModule,
],
controllers: [UploadController],
providers: [TusService],
})
export class UploadModule { }
export class UploadModule {}

View File

@ -29,7 +29,7 @@ export function SendCard({ staff, termId }: SendCardProps) {
{staff.meta?.photoUrl ? (
<img
src={staff.meta.photoUrl}
alt={staff.showname}
alt={staff?.showname}
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
/>
) : (
@ -61,7 +61,7 @@ export function SendCard({ staff, termId }: SendCardProps) {
<div>
<div className="flex items-center gap-3 mb-2">
<h3 className="text-2xl font-semibold text-gray-900">
{staff.showname}
{staff?.showname}
</h3>
<Badge status="success" />
</div>

View File

@ -63,7 +63,7 @@ const AdminHeader: React.FC<AdminHeaderProps> = ({
const localState = {
user: {
id: user.id,
showname: user.showname || user.username,
showname: user?.showname || user.username,
deptName: user.department?.name,
sessionId,
},

View File

@ -1,14 +1,22 @@
import { EyeOutlined, LikeOutlined, LikeFilled, UserOutlined, BankOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
import { Button, Typography, Space, Tooltip } from 'antd';
import toast from 'react-hot-toast';
import { useState } from 'react';
import { getBadgeStyle } from '@web/src/app/main/letter/list/utils';
import { PostDto } from '@nice/common';
import dayjs from 'dayjs';
import {
EyeOutlined,
LikeOutlined,
LikeFilled,
UserOutlined,
BankOutlined,
CalendarOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import { Button, Typography, Space, Tooltip } from "antd";
import toast from "react-hot-toast";
import { useState } from "react";
import { getBadgeStyle } from "@web/src/app/main/letter/list/utils";
import { PostDto } from "@nice/common";
import dayjs from "dayjs";
const { Title, Paragraph, Text } = Typography;
interface LetterCardProps {
letter: PostDto;
letter: PostDto;
}
export function LetterCard({ letter }: LetterCardProps) {
@ -45,32 +53,35 @@ export function LetterCard({ letter }: LetterCardProps) {
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
group-hover:before:w-full before:transition-all before:duration-300
group-hover:text-primary-600 group-hover:scale-105 group-hover:drop-shadow-md"
>
{letter.title}
</a>
</Title>
group-hover:text-primary-600 group-hover:scale-105 group-hover:drop-shadow-md">
{letter.title}
</a>
</Title>
</div>
</div>
{/* Meta Info */}
<div className="flex justify-between items-center text-sm text-secondary">
<Space size="middle">
<Space>
<UserOutlined className="text-secondary-400" />
<Text strong>{letter.author.showname}</Text>
</Space>
<Text type="secondary">|</Text>
<Space>
<BankOutlined className="text-secondary-400" />
<Text>{letter.author.department.name}</Text>
</Space>
</Space>
<Space>
<CalendarOutlined className="text-secondary-400" />
<Text type="secondary">{dayjs(letter.createdAt).format('YYYY-MM-DD')}</Text>
</Space>
</div>
{/* Meta Info */}
<div className="flex justify-between items-center text-sm text-secondary">
<Space size="middle">
<Space>
<UserOutlined className="text-secondary-400" />
<Text strong>
{letter.author?.showname ||
letter?.author?.username}
</Text>
</Space>
<Text type="secondary">|</Text>
<Space>
<BankOutlined className="text-secondary-400" />
<Text>{letter.author?.department?.name}</Text>
</Space>
</Space>
<Space>
<CalendarOutlined className="text-secondary-400" />
<Text type="secondary">
{dayjs(letter.createdAt).format("YYYY-MM-DD")}
</Text>
</Space>
</div>
{/* Content Preview */}
{letter.content && (
@ -84,12 +95,12 @@ export function LetterCard({ letter }: LetterCardProps) {
</div>
)}
{/* Badges & Interactions */}
<div className="flex justify-between items-center">
<Space size="small" wrap className="flex-1">
<Badge type="category" value={'11'} />
<Badge type="status" value={'22'} />
</Space>
{/* Badges & Interactions */}
<div className="flex justify-between items-center">
<Space size="small" wrap className="flex-1">
<Badge type="category" value={"11"} />
<Badge type="status" value={"22"} />
</Space>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1 text-gray-500">
@ -140,7 +151,6 @@ export function Badge({
transition-all duration-200 ease-in-out transform hover:scale-105
${className}
`}>
{value?.toUpperCase()}
</span>
)

View File

@ -9,6 +9,7 @@ import { PostDetailContext } from "./context/PostDetailContext";
import { LikeFilled, LikeOutlined } from "@ant-design/icons";
import PostLikeButton from "./PostHeader/PostLikeButton";
import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar";
import PostResources from "./PostResources";
export default function PostCommentCard({
post,
@ -19,40 +20,6 @@ export default function PostCommentCard({
index: number;
isReceiverComment: boolean;
}) {
const { user } = useContext(PostDetailContext);
const { like, unLike } = useVisitor();
const [liked, setLiked] = useState(post?.liked || false);
const [likeCount, setLikeCount] = useState(post?.likes || 0);
async function likeThisPost() {
if (!liked) {
try {
setLikeCount((prev) => prev + 1);
setLiked(true);
like.mutateAsync({
data: {
visitorId: user?.id || null,
postId: post.id,
type: VisitType.LIKE,
},
});
} 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 (
<motion.div
className="bg-white rounded-lg shadow-sm border border-slate-200 p-4"
@ -67,24 +34,31 @@ export default function PostCommentCard({
}></CustomAvatar>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-1 justify-between">
<div className="flex flex-1 justify-between ">
<div className="flex space-x-2" style={{ height: 40 }}>
<span className="font-medium text-slate-900">
<span className="flex font-medium text-slate-900">
{post.author?.showname || "匿名用户"}
</span>
<span className="text-sm text-slate-500">
<span className="flex 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 className=" ">
<span className=" px-2 text-sm rounded-full bg-blue-100 text-blue-800">
</span>
</div>
)}
</div>
{/* 添加有帮助按钮 */}
<PostLikeButton post={post}></PostLikeButton>
<div>
<div className="flex justify-center items-center gap-2">
<span className=" text-sm text-slate-500">{`#${index + 1}`}</span>
<PostLikeButton post={post}></PostLikeButton>
</div>
</div>
</div>
<div
@ -94,6 +68,7 @@ export default function PostCommentCard({
}}
dangerouslySetInnerHTML={{ __html: post.content || "" }}
/>
<PostResources post={post}></PostResources>
</div>
</div>
</motion.div>

View File

@ -33,7 +33,7 @@ export default function PostCommentList() {
},
select: postDetailSelect,
orderBy: [{ createdAt: "desc" }],
take: 3,
take: 5,
}),
[post, receiverIds]
);
@ -43,11 +43,14 @@ export default function PostCommentList() {
where: {
parentId: post?.id,
type: PostType.POST_COMMENT,
authorId: { notIn: receiverIds },
OR: [
{ authorId: null }, // 允许 authorId 为 null
{ authorId: { notIn: receiverIds } }, // 排除 receiverIds 中的 authorId
],
},
select: postDetailSelect,
orderBy: [{ createdAt: "desc" }],
take: 3,
take: 5,
}),
[post, receiverIds]
);
@ -147,6 +150,12 @@ 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,171 +0,0 @@
import { useContext } from "react";
import { PostDetailContext } from "./context/PostDetailContext";
import { motion } from "framer-motion";
import {
CalendarIcon,
UserCircleIcon,
LockClosedIcon,
LockOpenIcon,
StarIcon,
ClockIcon,
EyeIcon,
ChatBubbleLeftIcon,
} from "@heroicons/react/24/outline";
import { Button, Typography, Space, Tooltip } from "antd";
import { useVisitor } from "@nice/client";
import { PostState, VisitType } from "@nice/common";
import {
CalendarOutlined,
ClockCircleOutlined,
CommentOutlined,
EyeOutlined,
FileTextOutlined,
FolderOutlined,
LikeFilled,
LikeOutlined,
LockOutlined,
UnlockOutlined,
UserOutlined,
} 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";
import { PostBadge } from "./badge/PostBadge";
const { Title, Paragraph, Text } = Typography;
export default function PostHeader() {
const { post, user } = useContext(PostDetailContext);
const { like, unLike } = useVisitor();
function likeThisPost() {
if (!post?.liked) {
post.likes += 1;
post.liked = true;
like.mutateAsync({
data: {
visitorId: user?.id || null,
postId: post.id,
type: VisitType.LIKE,
},
});
} else {
post.likes -= 1;
post.liked = false;
unLike.mutateAsync({
where: {
visitorId: user?.id || null,
postId: post.id,
type: VisitType.LIKE,
},
});
}
}
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="relative bg-gradient-to-br from-primary-250 via-primary-150 to--primary-350 rounded-lg p-6 shadow-lg border border-[#97A9C4]/30">
{/* Corner Decorations */}
<div className="absolute top-0 left-0 w-5 h-5 border-t-4 border-l-4 border-primary rounded-tl-lg" />
<div className="absolute bottom-0 right-0 w-5 h-5 border-b-4 border-r-4 border-primary rounded-br-lg" />
{/* Title Section */}
<TitleSection></TitleSection>
<div className="space-y-4">
{/* 收件人信息行 */}
<Space>
<UserOutlined className="text-secondary-400" />
<span className="text-secondary-400"></span>
{post?.receivers?.map((receiver, index) => (
<Text strong key={`${index}`}>
{receiver?.showname}
</Text>
))}
</Space>
{/* First Row - Basic Info */}
<div className="flex flex-wrap items-center gap-1">
{/* Author Info Badge */}
<Space>
<UserOutlined className="text-secondary-400" />
<span className="text-secondary-400"></span>
<Text strong>
{post?.author?.showname || "匿名用户"}
</Text>
</Space>
<Text type="secondary">|</Text>
{/* Date Info Badge */}
<Space>
<CalendarOutlined className="text-secondary-400" />
<Text>
:
{dayjs(post?.createdAt).format("YYYY-MM-DD")}
</Text>
</Space>
<Text type="secondary">|</Text>
{/* Last Updated Badge */}
<Space>
<ClockCircleOutlined className="text-secondary-400" />
<Text>
:
{dayjs(post?.updatedAt).format("YYYY-MM-DD")}
</Text>
</Space>
<Text type="secondary">|</Text>
{/* Visibility Status Badge */}
<Space>
{post?.isPublic ? (
<UnlockOutlined className="text-secondary-400" />
) : (
<LockOutlined className="text-secondary-400" />
)}
<Text>{post?.isPublic ? "公开" : "私信"}</Text>
</Space>
</div>
{/* Second Row - Term and Tags */}
{post?.meta?.tags?.length > 0 && (
<div className="flex flex-wrap gap-1">
{/* Tags Badges */}
{post.meta.tags.length > 0 &&
post.meta.tags.map((tag, index) => (
<Space key={index}>
<PostBadge
type="tag"
value={`#${tag}`}></PostBadge>
</Space>
))}
</div>
)}
</div>
{/* Content Section */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="mt-6 text-secondary-700">
<div
className="ql-editor p-0 space-y-4 leading-relaxed duration-300"
dangerouslySetInnerHTML={{ __html: post?.content || "" }}
/>
</motion.div>
{/* Stats Section */}
<StatsSection></StatsSection>
</motion.div>
);
}

View File

@ -0,0 +1,35 @@
import { useContext } from "react";
import { PostDetailContext } from "../context/PostDetailContext";
import { motion } from "framer-motion";
import { StatsSection } from "./StatsSection";
import PostResources from "../PostResources";
export default function Content() {
const { post, user } = useContext(PostDetailContext);
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">
<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"
dangerouslySetInnerHTML={{
__html: post?.content || "",
}}
/>
<PostResources post={post}></PostResources>
{/* <div>{post.resources?.map((resource) => {})}</div> */}
</motion.div>
{/* Stats Section */}
<StatsSection></StatsSection>
</motion.div>
);
}

View File

@ -0,0 +1,104 @@
import { useContext } from "react";
import { PostDetailContext } from "../context/PostDetailContext";
import { Space, Typography } from "antd";
import { PostBadge } from "../badge/PostBadge";
import {
CalendarOutlined,
ClockCircleOutlined,
LockOutlined,
UnlockOutlined,
UserOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
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">
{/* 主标题 */}
<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">
{/* 收件人信息行 */}
<Space>
<UserOutlined className="text-white" />
<span className="text-white"></span>
{post?.receivers?.map((receiver, index) => (
<Text
strong
className="text-white"
key={`${index}`}>
{receiver?.showname}
</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 */}
{post.meta.tags.length > 0 &&
post.meta.tags.map((tag, index) => (
<Space key={index}>
<PostBadge
type="tag"
value={`#${tag}`}></PostBadge>
</Space>
))}
</div>
)}
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,20 @@
import { useContext } from "react";
import { PostDetailContext } from "../context/PostDetailContext";
import { motion } from "framer-motion";
import { StatsSection } from "./StatsSection";
import PostResources from "../PostResources";
import Header from "./Header";
import Content from "./Content";
export default function PostHeader() {
const { post, user } = useContext(PostDetailContext);
return (
<>
<Header></Header>
<Content></Content>
</>
);
}

View File

@ -1,48 +0,0 @@
import { motion } from "framer-motion";
import { Space, Tag } from "antd";
import { PostState } from "@nice/common";
import {
ClockIcon,
CheckCircleIcon,
ExclamationCircleIcon,
} from "@heroicons/react/24/outline";
import { Badge } from "@web/src/app/main/letter/list/LetterCard";
import { useContext } from "react";
import { PostDetailContext } from "../context/PostDetailContext";
import { PostBadge } from "../badge/PostBadge";
interface TitleSectionProps {
title: string;
state: PostState;
}
const stateLabels = {
[PostState.PENDING]: "待处理",
[PostState.PROCESSING]: "处理中",
[PostState.RESOLVED]: "已完成",
};
export function TitleSection() {
const { post, user } = useContext(PostDetailContext);
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-primary" /> */}
{/* Title */}
<h1 className="text-xl font-bold text-primary tracking-wider uppercase">
{post?.title}
</h1>
<Space size="small" wrap className="flex-1">
<PostBadge type="category" value={post?.term?.name} />
<PostBadge type="state" value={post?.state} />
</Space>
{/* </motion.div> */}
</motion.div>
);
}

View File

@ -0,0 +1,53 @@
import React, { useContext, useMemo } from "react";
import { Image, Button } from "antd";
import { DownloadOutlined } 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";
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]);
const isImage = (url: string) => {
return /\.(png|jpg|jpeg|gif|webp)$/i.test(url);
};
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) ? (
<>
<Image
src={resource.url}
alt={resource.title}
className="rounded-lg"
width={"100%"}
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>
);
}

View File

@ -1,7 +1,8 @@
import { motion } from "framer-motion";
import { useContext, useEffect } from "react";
import { PostDetailContext } from "../context/PostDetailContext";
import PostHeader from "../PostHeader";
import PostHeader from "../PostHeader/PostHeader";
import WriteHeader from "../PostHeader/Header";
import PostCommentEditor from "../PostCommentEditor";
import PostCommentList from "../PostCommentList";
import { useVisitor } from "@nice/client";

View File

@ -71,11 +71,11 @@ server {
# 文件访问认证
# 通过内部认证服务验证
auth_request /auth-file;
# auth_request /auth-file;
# 存储认证状态和用户信息
auth_request_set $auth_status $upstream_status;
auth_request_set $auth_user_id $upstream_http_x_user_id;
auth_request_set $auth_resource_type $upstream_http_x_resource_type;
# auth_request_set $auth_status $upstream_status;
# auth_request_set $auth_user_id $upstream_http_x_user_id;
# auth_request_set $auth_resource_type $upstream_http_x_resource_type;
# 不缓存
expires 0;
# 私有缓存,禁止转换