This commit is contained in:
longdayi 2025-01-25 19:51:53 +08:00
commit 7919edec06
26 changed files with 616 additions and 382 deletions

View File

@ -23,12 +23,12 @@ import { UploadModule } from './upload/upload.module';
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, // 全局可用 isGlobal: true, // 全局可用
envFilePath: '.env' envFilePath: '.env',
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
JwtModule.register({ JwtModule.register({
global: true, global: true,
secret: env.JWT_SECRET secret: env.JWT_SECRET,
}), }),
WebSocketModule, WebSocketModule,
TrpcModule, TrpcModule,
@ -42,11 +42,13 @@ import { UploadModule } from './upload/upload.module';
MinioModule, MinioModule,
CollaborationModule, CollaborationModule,
RealTimeModule, 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 并允许所有来源 // 启用 CORS 并允许所有来源
app.enableCors({ app.enableCors({
origin: "*", origin: '*',
}); });
const wsService = app.get(WebSocketService); const wsService = app.get(WebSocketService);
await wsService.initialize(app.getHttpServer()); await wsService.initialize(app.getHttpServer());
@ -18,6 +18,5 @@ async function bootstrap() {
const port = process.env.SERVER_PORT || 3000; const port = process.env.SERVER_PORT || 3000;
await app.listen(port); await app.listen(port);
} }
bootstrap(); bootstrap();

View File

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

View File

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

View File

@ -85,6 +85,7 @@ export function getClientIp(req: any): string {
return ip || ''; return ip || '';
} }
export async function updatePostState(id: string) { export async function updatePostState(id: string) {
console.log('updateState');
const post = await db.post.findUnique({ const post = await db.post.findUnique({
where: { where: {
id: id, id: id,

View File

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

View File

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

View File

@ -1,7 +1,196 @@
import React, { useEffect, useState } from "react"; import React, { useCallback, useState } from "react";
import { UploadOutlined } from "@ant-design/icons"; import {
import { Form, Upload, message } from "antd"; UploadOutlined,
CheckCircleOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import { Upload, message, Progress, Button } from "antd";
import type { UploadFile } from "antd";
import { useTusUpload } from "@web/src/hooks/useTusUpload";
export const TusUploader = ({ value = [], onChange }) => { export interface TusUploaderProps {
return <Upload.Dragger></Upload.Dragger>; value?: string[];
onChange?: (value: string[]) => void;
}
interface UploadingFile {
name: string;
progress: number;
status: "uploading" | "done" | "error";
fileId?: string;
}
export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
const { handleFileUpload } = useTusUpload();
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
const [completedFiles, setCompletedFiles] = useState<UploadingFile[]>(() =>
value?.map(fileId => ({
name: `File ${fileId}`, // We could fetch the actual filename if needed
progress: 1,
status: 'done' as const,
fileId
})) || []
);
const [uploadResults, setUploadResults] = useState<string[]>(value || []);
const handleRemoveFile = useCallback(
(fileId: string) => {
setCompletedFiles((prev) =>
prev.filter((f) => f.fileId !== fileId)
);
const newResults = uploadResults.filter(id => id !== fileId);
setUploadResults(newResults);
onChange?.(newResults);
},
[uploadResults, onChange]
);
const handleChange = useCallback(
async (fileList: UploadFile | UploadFile[]) => {
const files = Array.isArray(fileList) ? fileList : [fileList];
console.log("files", files);
// 验证文件对象
if (!files.every((f) => f instanceof File)) {
message.error("Invalid file format");
return false;
}
const newFiles: UploadingFile[] = files.map((f) => ({
name: f.name,
progress: 0,
status: "uploading" as const,
}));
setUploadingFiles((prev) => [...prev, ...newFiles]);
const newUploadResults: string[] = [];
try {
for (const [index, f] of files.entries()) {
if (!f) {
throw new Error(`File ${f.name} is invalid`);
}
const fileId = await new Promise<string>(
(resolve, reject) => {
handleFileUpload(
f as File,
(result) => {
console.log("Upload success:", result);
const completedFile = {
name: f.name,
progress: 1,
status: "done" as const,
fileId: result.fileId,
};
setCompletedFiles((prev) => [
...prev,
completedFile,
]);
setUploadingFiles((prev) =>
prev.filter((_, i) => i !== index)
);
resolve(result.fileId);
},
(error) => {
console.error("Upload error:", error);
reject(error);
}
);
}
);
newUploadResults.push(fileId);
}
// Update with all uploaded files
const newValue = Array.from(new Set([...uploadResults, ...newUploadResults]));
setUploadResults(newValue);
onChange?.(newValue);
message.success(`${files.length} files uploaded successfully`);
} catch (error) {
console.error("Upload error details:", error);
message.error(
`Upload failed: ${error instanceof Error ? error.message : "Unknown error"}`
);
setUploadingFiles((prev) =>
prev.map((f) => ({ ...f, status: "error" }))
);
}
return false;
},
[uploadResults, onChange, handleFileUpload]
);
return (
<div className="space-y-4">
<Upload.Dragger
name="files"
multiple
showUploadList={false}
beforeUpload={handleChange}
style={{
border: "2px dashed #1677ff",
borderRadius: "8px",
backgroundColor: "#f0f8ff",
}}>
<p className="ant-upload-drag-icon">
<UploadOutlined />
</p>
<p className="ant-upload-text">
Click or drag file to this area to upload
</p>
<p className="ant-upload-hint">
Support for a single or bulk upload of files
</p>
</Upload.Dragger>
{/* Uploading Files */}
{uploadingFiles.length > 0 && (
<div className="space-y-2 p-4 border rounded">
<div className="font-medium">Uploading Files</div>
{uploadingFiles.map((file, index) => (
<div key={index} className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="text-sm">{file.name}</div>
</div>
<Progress
percent={Math.round(file.progress * 100)}
status={
file.status === "error"
? "exception"
: file.status === "done"
? "success"
: "active"
}
/>
</div>
))}
</div>
)}
{/* Completed Files */}
{completedFiles.length > 0 && (
<div className="space-y-2 p-4 border rounded">
<div className="font-medium">Uploaded Files</div>
{completedFiles.map((file, index) => (
<div
key={index}
className="flex items-center justify-between gap-2 py-2">
<div className="flex items-center gap-2">
<CheckCircleOutlined className="text-green-500" />
<div className="text-sm">{file.name}</div>
</div>
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() =>
file.fileId && handleRemoveFile(file.fileId)
}
/>
</div>
))}
</div>
)}
</div>
);
}; };

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import { PostDetailContext } from "./context/PostDetailContext";
import { LikeFilled, LikeOutlined } from "@ant-design/icons"; import { LikeFilled, LikeOutlined } from "@ant-design/icons";
import PostLikeButton from "./PostHeader/PostLikeButton"; import PostLikeButton from "./PostHeader/PostLikeButton";
import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar"; import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar";
import PostResources from "./PostResources";
export default function PostCommentCard({ export default function PostCommentCard({
post, post,
@ -19,40 +20,6 @@ export default function PostCommentCard({
index: number; index: number;
isReceiverComment: boolean; 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 ( return (
<motion.div <motion.div
className="bg-white rounded-lg shadow-sm border border-slate-200 p-4" className="bg-white rounded-lg shadow-sm border border-slate-200 p-4"
@ -67,24 +34,31 @@ export default function PostCommentCard({
}></CustomAvatar> }></CustomAvatar>
</div> </div>
<div className="flex-1 min-w-0"> <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 }}> <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 || "匿名用户"} {post.author?.showname || "匿名用户"}
</span> </span>
<span className="text-sm text-slate-500"> <span className="flex text-sm text-slate-500">
{dayjs(post?.createdAt).format( {dayjs(post?.createdAt).format(
"YYYY-MM-DD HH:mm" "YYYY-MM-DD HH:mm"
)} )}
</span> </span>
{isReceiverComment && ( {isReceiverComment && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800"> <div className=" ">
<span className=" px-2 text-sm rounded-full bg-blue-100 text-blue-800">
</span>
</span>
</div>
)} )}
</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>
<div <div
@ -94,6 +68,7 @@ export default function PostCommentCard({
}} }}
dangerouslySetInnerHTML={{ __html: post.content || "" }} dangerouslySetInnerHTML={{ __html: post.content || "" }}
/> />
<PostResources post={post}></PostResources>
</div> </div>
</div> </div>
</motion.div> </motion.div>

View File

@ -14,6 +14,7 @@ export default function PostCommentEditor() {
const { post } = useContext(PostDetailContext); const { post } = useContext(PostDetailContext);
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [isPreview, setIsPreview] = useState(false); const [isPreview, setIsPreview] = useState(false);
const [fileIds, setFileIds] = useState<string[]>([]);
const { create } = usePost(); const { create } = usePost();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -26,8 +27,14 @@ export default function PostCommentEditor() {
await create.mutateAsync({ await create.mutateAsync({
data: { data: {
type: PostType.POST_COMMENT, type: PostType.POST_COMMENT,
parentId: post?.id, parentId: post?.id,
content: content, content: content,
resources: {
connect: fileIds.filter(Boolean).map((id) => ({
fileId: id,
})),
},
}, },
}); });
toast.success("发布成功!"); toast.success("发布成功!");
@ -83,7 +90,10 @@ export default function PostCommentEditor() {
)} )}
</div> </div>
<TusUploader></TusUploader> <TusUploader
onChange={(value) => {
setFileIds(value);
}}></TusUploader>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<motion.div <motion.div

View File

@ -33,7 +33,7 @@ export default function PostCommentList() {
}, },
select: postDetailSelect, select: postDetailSelect,
orderBy: [{ createdAt: "desc" }], orderBy: [{ createdAt: "desc" }],
take: 3, take: 5,
}), }),
[post, receiverIds] [post, receiverIds]
); );
@ -43,11 +43,14 @@ export default function PostCommentList() {
where: { where: {
parentId: post?.id, parentId: post?.id,
type: PostType.POST_COMMENT, type: PostType.POST_COMMENT,
authorId: { notIn: receiverIds }, OR: [
{ authorId: null }, // 允许 authorId 为 null
{ authorId: { notIn: receiverIds } }, // 排除 receiverIds 中的 authorId
],
}, },
select: postDetailSelect, select: postDetailSelect,
orderBy: [{ createdAt: "desc" }], orderBy: [{ createdAt: "desc" }],
take: 3, take: 5,
}), }),
[post, receiverIds] [post, receiverIds]
); );
@ -147,6 +150,12 @@ 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,166 +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>
<Text strong>
{post?.receivers?.map((receiver) => 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 */}
<div className="flex flex-wrap gap-1">
{/* Tags Badges */}
{post?.meta?.tags &&
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 { motion } from "framer-motion";
import { useContext, useEffect } from "react"; import { useContext, useEffect } from "react";
import { PostDetailContext } from "../context/PostDetailContext"; import { PostDetailContext } from "../context/PostDetailContext";
import PostHeader from "../PostHeader"; import PostHeader from "../PostHeader/PostHeader";
import WriteHeader from "../PostHeader/Header";
import PostCommentEditor from "../PostCommentEditor"; import PostCommentEditor from "../PostCommentEditor";
import PostCommentList from "../PostCommentList"; import PostCommentList from "../PostCommentList";
import { useVisitor } from "@nice/client"; import { useVisitor } from "@nice/client";

View File

@ -9,6 +9,8 @@ export interface LetterFormData {
title: string; title: string;
content: string; content: string;
resources?: string[]; resources?: string[];
receivers?: string[];
terms?: string[];
isPublic?: boolean; isPublic?: boolean;
signature?: string; signature?: string;
meta: { meta: {
@ -43,24 +45,38 @@ export function LetterFormProvider({
const onSubmit = async (data: LetterFormData) => { const onSubmit = async (data: LetterFormData) => {
try { try {
console.log("data", data); console.log("data", data);
const receivers = data?.receivers;
const terms = data?.terms;
delete data.receivers;
delete data.terms;
const result = await create.mutateAsync({ const result = await create.mutateAsync({
data: { data: {
...data,
type: PostType.POST, type: PostType.POST,
termId: termId,
receivers: { terms: {
connect: [receiverId].filter(Boolean).map((id) => ({ connect: (terms || [])?.filter(Boolean).map((id) => ({
id, id,
})), })),
}, },
state: PostState.PENDING, receivers: {
isPublic: data?.isPublic, connect: (receivers || [])
...data, ?.filter(Boolean)
resources: data.resources?.length .map((id) => ({
? {
connect: data.resources.map((id) => ({
id, id,
})), })),
} },
state: PostState.PENDING,
isPublic: data?.isPublic,
resources: data.resources?.length
? {
connect: (
data.resources?.filter(Boolean) || []
).map((fileId) => ({
fileId,
})),
}
: undefined, : undefined,
}, },
}); });

View File

@ -2,6 +2,8 @@ import { Form, Input, Button, Checkbox, Select } from "antd";
import { useLetterEditor } from "../context/LetterEditorContext"; import { useLetterEditor } from "../context/LetterEditorContext";
import { SendOutlined } from "@ant-design/icons"; import { SendOutlined } from "@ant-design/icons";
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor"; import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
import { PostBadge } from "../../detail/badge/PostBadge";
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
import StaffSelect from "../../../staff/staff-select"; import StaffSelect from "../../../staff/staff-select";
import TermSelect from "../../../term/term-select"; import TermSelect from "../../../term/term-select";
@ -13,30 +15,30 @@ export function LetterBasicForm() {
return ( return (
<div className=" p-6 "> <div className=" p-6 ">
<Form <Form
size="large" size="large"
form={form} form={form}
onFinish={handleFinish} onFinish={handleFinish}
initialValues={{ meta: { tags: [] }, receiverId, termId, isPublic: true }} initialValues={{
> meta: { tags: [] },
receiverId,
termId,
isPublic: true,
}}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Form.Item label='收件人' name={'receiverId'}> <Form.Item label="收件人" name={"receivers"}>
<StaffSelect multiple placeholder="选择收信人员" /> <StaffSelect multiple placeholder="选择收信人员" />
</Form.Item> </Form.Item>
<Form.Item label='分类' name={'termId'}> <Form.Item label="分类" name={"terms"}>
<TermSelect placeholder="选择信件分类" /> <TermSelect placeholder="选择信件分类" />
</Form.Item> </Form.Item>
</div> </div>
<Form.Item <Form.Item
name="title" name="title"
rules={[{ required: true, message: "请输入信件标题" }]} rules={[{ required: true, message: "请输入信件标题" }]}>
>
<Input <Input
maxLength={20} maxLength={20}
showCount showCount
placeholder="请输入信件标题" placeholder="请输入信件标题"
/> />
</Form.Item> </Form.Item>
@ -46,7 +48,9 @@ export function LetterBasicForm() {
mode="tags" mode="tags"
placeholder="输入标签后按回车添加" placeholder="输入标签后按回车添加"
value={form.getFieldValue(["meta", "tags"]) || []} value={form.getFieldValue(["meta", "tags"]) || []}
onChange={(value) => form.setFieldValue(["meta", "tags"], value)} onChange={(value) =>
form.setFieldValue(["meta", "tags"], value)
}
tokenSeparators={[",", " "]} tokenSeparators={[",", " "]}
className="w-full" className="w-full"
dropdownStyle={{ display: "none" }} dropdownStyle={{ display: "none" }}
@ -55,8 +59,7 @@ export function LetterBasicForm() {
{label} {label}
<span <span
className="ml-2 cursor-pointer hover:text-primary-700" className="ml-2 cursor-pointer hover:text-primary-700"
onClick={onClose} onClick={onClose}>
>
× ×
</span> </span>
</div> </div>
@ -68,26 +71,32 @@ export function LetterBasicForm() {
<Form.Item <Form.Item
name="content" name="content"
rules={[{ required: true, message: "请输入内容" }]} rules={[{ required: true, message: "请输入内容" }]}
required={false} required={false}>
>
<div className="rounded-lg border border-gray-200 bg-white shadow-sm"> <div className="rounded-lg border border-gray-200 bg-white shadow-sm">
<QuillEditor <QuillEditor
maxLength={10000} maxLength={10000}
placeholder="请输入内容" placeholder="请输入内容"
minRows={16} minRows={16}
onChange={(content) => form.setFieldValue("content", content)} onChange={(content) =>
form.setFieldValue("content", content)
}
/>
</div>
</Form.Item>
<Form.Item name="resources" required={false}>
<div className="rounded-lg border border-gray-200 bg-white shadow-sm">
<TusUploader
onChange={(resources) =>
form.setFieldValue("resources", resources)
}
/> />
</div> </div>
</Form.Item> </Form.Item>
{/* Footer Actions */} {/* Footer Actions */}
<div className="flex flex-col-reverse sm:flex-row items-center justify-between gap-4 mt-6"> <div className="flex flex-col-reverse sm:flex-row items-center justify-between gap-4 mt-6">
<Form.Item <Form.Item
name="isPublic" name="isPublic"
valuePropName="checked" valuePropName="checked"
> >
<Checkbox className="text-gray-600 hover:text-gray-900 transition-colors text-sm"> <Checkbox className="text-gray-600 hover:text-gray-900 transition-colors text-sm">
@ -98,8 +107,7 @@ export function LetterBasicForm() {
onClick={() => form.submit()} onClick={() => form.submit()}
size="large" size="large"
icon={<SendOutlined />} icon={<SendOutlined />}
className="w-full sm:w-40" className="w-full sm:w-40">
>
</Button> </Button>
</div> </div>

View File

@ -26,10 +26,19 @@ export function useTusUpload() {
onSuccess: (result: UploadResult) => void, onSuccess: (result: UploadResult) => void,
onError: (error: Error) => void onError: (error: Error) => void
) => { ) => {
if (!file || !file.name || !file.type) {
const error = new Error('Invalid file provided');
setUploadError(error.message);
onError(error);
return;
}
setIsUploading(true); setIsUploading(true);
setProgress(0); setProgress(0);
setUploadError(null); setUploadError(null);
const upload = new tus.Upload(file, {
try {
const upload = new tus.Upload(file, {
endpoint: "http://localhost:3000/upload", endpoint: "http://localhost:3000/upload",
retryDelays: [0, 1000, 3000, 5000], retryDelays: [0, 1000, 3000, 5000],
metadata: { metadata: {
@ -73,7 +82,14 @@ export function useTusUpload() {
}, },
}); });
upload.start(); upload.start();
} catch (error) {
const err = error instanceof Error ? error : new Error("Upload failed");
setIsUploading(false);
setUploadError(err.message);
onError(err);
}
}; };
return { return {
progress, progress,
isUploading, isUploading,

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_status $upstream_status;
auth_request_set $auth_user_id $upstream_http_x_user_id; # 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_resource_type $upstream_http_x_resource_type;
# 不缓存 # 不缓存
expires 0; expires 0;
# 私有缓存,禁止转换 # 私有缓存,禁止转换

View File

@ -27,7 +27,8 @@ model Taxonomy {
model Term { model Term {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
posts Post[] // posts Post[]
posts Post[] @relation("post_term")
taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id]) taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id])
taxonomyId String? @map("taxonomy_id") taxonomyId String? @map("taxonomy_id")
order Float? @map("order") order Float? @map("order")
@ -193,8 +194,10 @@ model Post {
content String? // 帖子内容,可为空 content String? // 帖子内容,可为空
domainId String? @map("domain_id") domainId String? @map("domain_id")
term Term? @relation(fields: [termId], references: [id]) // term Term? @relation(fields: [termId], references: [id])
termId String? @map("term_id") // termId String? @map("term_id")
// 添加多对多关系
terms Term[] @relation("post_term")
// 日期时间类型字段 // 日期时间类型字段
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @map("updated_at") updatedAt DateTime @map("updated_at")

View File

@ -12,16 +12,10 @@ export const postDetailSelect: Prisma.PostSelect = {
resources: true, resources: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
termId: true,
term: { terms: {
include: { include: {
taxonomy: {
select: {
id: true,
slug: true,
name: true,
},
},
}, },
}, },
authorId: true, authorId: true,