diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 3bc892f..94013f0 100755 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -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 {} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 1875a5f..8b82ed1 100755 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -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(); diff --git a/apps/server/src/models/post/post.router.ts b/apps/server/src/models/post/post.router.ts index e1d1b33..678452e 100755 --- a/apps/server/src/models/post/post.router.ts +++ b/apps/server/src/models/post/post.router.ts @@ -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); }), diff --git a/apps/server/src/models/post/post.service.ts b/apps/server/src/models/post/post.service.ts index 5372060..05637e2 100755 --- a/apps/server/src/models/post/post.service.ts +++ b/apps/server/src/models/post/post.service.ts @@ -33,6 +33,7 @@ export class PostService extends BaseService { 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 diff --git a/apps/server/src/models/post/utils.ts b/apps/server/src/models/post/utils.ts index 57a4a2c..64cf549 100644 --- a/apps/server/src/models/post/utils.ts +++ b/apps/server/src/models/post/utils.ts @@ -85,6 +85,7 @@ export function getClientIp(req: any): string { return ip || ''; } export async function updatePostState(id: string) { + console.log('updateState'); const post = await db.post.findUnique({ where: { id: id, diff --git a/apps/server/src/upload/upload.module.ts b/apps/server/src/upload/upload.module.ts index 0a54ccb..6c8e1b0 100644 --- a/apps/server/src/upload/upload.module.ts +++ b/apps/server/src/upload/upload.module.ts @@ -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 { } \ No newline at end of file +export class UploadModule {} diff --git a/apps/web/src/app/main/letter/write/SendCard.tsx b/apps/web/src/app/main/letter/write/SendCard.tsx index 3c67a97..1099264 100644 --- a/apps/web/src/app/main/letter/write/SendCard.tsx +++ b/apps/web/src/app/main/letter/write/SendCard.tsx @@ -29,7 +29,7 @@ export function SendCard({ staff, termId }: SendCardProps) { {staff.meta?.photoUrl ? ( {staff.showname} ) : ( @@ -61,7 +61,7 @@ export function SendCard({ staff, termId }: SendCardProps) {

- {staff.showname} + {staff?.showname}

diff --git a/apps/web/src/components/common/uploader/TusUploader.tsx b/apps/web/src/components/common/uploader/TusUploader.tsx index 4211322..b3ec1a3 100644 --- a/apps/web/src/components/common/uploader/TusUploader.tsx +++ b/apps/web/src/components/common/uploader/TusUploader.tsx @@ -1,7 +1,196 @@ -import React, { useEffect, useState } from "react"; -import { UploadOutlined } from "@ant-design/icons"; -import { Form, Upload, message } from "antd"; +import React, { useCallback, useState } from "react"; +import { + 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 }) => { - return ; +export interface TusUploaderProps { + 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([]); + const [completedFiles, setCompletedFiles] = useState(() => + 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(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( + (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 ( +
+ +

+ +

+

+ Click or drag file to this area to upload +

+

+ Support for a single or bulk upload of files +

+
+ + {/* Uploading Files */} + {uploadingFiles.length > 0 && ( +
+
Uploading Files
+ {uploadingFiles.map((file, index) => ( +
+
+
{file.name}
+
+ +
+ ))} +
+ )} + + {/* Completed Files */} + {completedFiles.length > 0 && ( +
+
Uploaded Files
+ {completedFiles.map((file, index) => ( +
+
+ +
{file.name}
+
+
+ ))} +
+ )} +
+ ); }; diff --git a/apps/web/src/components/layout/admin/AdminHeader.tsx b/apps/web/src/components/layout/admin/AdminHeader.tsx index 6a2a22a..a62bf3d 100644 --- a/apps/web/src/components/layout/admin/AdminHeader.tsx +++ b/apps/web/src/components/layout/admin/AdminHeader.tsx @@ -63,7 +63,7 @@ const AdminHeader: React.FC = ({ const localState = { user: { id: user.id, - showname: user.showname || user.username, + showname: user?.showname || user.username, deptName: user.department?.name, sessionId, }, diff --git a/apps/web/src/components/models/post/LetterCard.tsx b/apps/web/src/components/models/post/LetterCard.tsx index 9647bfc..0c9e6a9 100644 --- a/apps/web/src/components/models/post/LetterCard.tsx +++ b/apps/web/src/components/models/post/LetterCard.tsx @@ -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} - - + group-hover:text-primary-600 group-hover:scale-105 group-hover:drop-shadow-md"> + {letter.title} + + +
- - - {/* Meta Info */} -
- - - - {letter.author.showname} - - | - - - {letter.author.department.name} - - - - - {dayjs(letter.createdAt).format('YYYY-MM-DD')} - -
+ {/* Meta Info */} +
+ + + + + {letter.author?.showname || + letter?.author?.username} + + + | + + + {letter.author?.department?.name} + + + + + + {dayjs(letter.createdAt).format("YYYY-MM-DD")} + + +
{/* Content Preview */} {letter.content && ( @@ -84,12 +95,12 @@ export function LetterCard({ letter }: LetterCardProps) { )} - {/* Badges & Interactions */} -
- - - - + {/* Badges & Interactions */} +
+ + + +
@@ -140,7 +151,6 @@ export function Badge({ transition-all duration-200 ease-in-out transform hover:scale-105 ${className} `}> - {value?.toUpperCase()} ) diff --git a/apps/web/src/components/models/post/detail/PostCommentCard.tsx b/apps/web/src/components/models/post/detail/PostCommentCard.tsx index 828cbaf..e640773 100644 --- a/apps/web/src/components/models/post/detail/PostCommentCard.tsx +++ b/apps/web/src/components/models/post/detail/PostCommentCard.tsx @@ -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 (
-
+
- + {post.author?.showname || "匿名用户"} - + {dayjs(post?.createdAt).format( "YYYY-MM-DD HH:mm" )} {isReceiverComment && ( - - 官方回答 - +
+ + 官方回答 + +
)}
{/* 添加有帮助按钮 */} - +
+
+ {`#${index + 1}`} + +
+
+
diff --git a/apps/web/src/components/models/post/detail/PostCommentEditor.tsx b/apps/web/src/components/models/post/detail/PostCommentEditor.tsx index 292a883..8575a74 100644 --- a/apps/web/src/components/models/post/detail/PostCommentEditor.tsx +++ b/apps/web/src/components/models/post/detail/PostCommentEditor.tsx @@ -14,6 +14,7 @@ export default function PostCommentEditor() { const { post } = useContext(PostDetailContext); const [content, setContent] = useState(""); const [isPreview, setIsPreview] = useState(false); + const [fileIds, setFileIds] = useState([]); const { create } = usePost(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -26,8 +27,14 @@ export default function PostCommentEditor() { await create.mutateAsync({ data: { type: PostType.POST_COMMENT, + parentId: post?.id, content: content, + resources: { + connect: fileIds.filter(Boolean).map((id) => ({ + fileId: id, + })), + }, }, }); toast.success("发布成功!"); @@ -83,7 +90,10 @@ export default function PostCommentEditor() { )}
- + { + setFileIds(value); + }}>
暂无回复,来发表第一条回复吧 + ); } diff --git a/apps/web/src/components/models/post/detail/PostHeader.tsx b/apps/web/src/components/models/post/detail/PostHeader.tsx deleted file mode 100644 index 8daf65d..0000000 --- a/apps/web/src/components/models/post/detail/PostHeader.tsx +++ /dev/null @@ -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 ( - - {/* Corner Decorations */} -
-
- - {/* Title Section */} - - - -
- {/* 收件人信息行 */} - - - 收件人: - - {post?.receivers?.map((receiver) => receiver?.showname)} - - - - {/* First Row - Basic Info */} -
- {/* Author Info Badge */} - - - 发件人: - - {post?.author?.showname || "匿名用户"} - - - | - {/* Date Info Badge */} - - - - - 创建于: - {dayjs(post?.createdAt).format("YYYY-MM-DD")} - - - | - {/* Last Updated Badge */} - - - - 更新于: - {dayjs(post?.updatedAt).format("YYYY-MM-DD")} - - - | - {/* Visibility Status Badge */} - - {post?.isPublic ? ( - - ) : ( - - )} - {post?.isPublic ? "公开" : "私信"} - -
- {/* Second Row - Term and Tags */} -
- {/* Tags Badges */} - {post?.meta?.tags && - post.meta.tags.length > 0 && - post.meta.tags.map((tag, index) => ( - - - - ))} -
-
- - {/* Content Section */} - -
- - - {/* Stats Section */} - - - ); -} diff --git a/apps/web/src/components/models/post/detail/PostHeader/Content.tsx b/apps/web/src/components/models/post/detail/PostHeader/Content.tsx new file mode 100644 index 0000000..b9f3da5 --- /dev/null +++ b/apps/web/src/components/models/post/detail/PostHeader/Content.tsx @@ -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 ( + + +
+ + {/*
{post.resources?.map((resource) => {})}
*/} + + + {/* Stats Section */} + + + ); +} diff --git a/apps/web/src/components/models/post/detail/PostHeader/Header.tsx b/apps/web/src/components/models/post/detail/PostHeader/Header.tsx new file mode 100644 index 0000000..41a839b --- /dev/null +++ b/apps/web/src/components/models/post/detail/PostHeader/Header.tsx @@ -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 ( +
+
+ {/* 主标题 */} +
+

+ {post?.title} + + + +

+
+
+ {/* 收件人信息行 */} + + + 收件人: + + {post?.receivers?.map((receiver, index) => ( + + {receiver?.showname} + + ))} + + + {/* First Row - Basic Info */} +
+ {/* Author Info Badge */} + + + 发件人: + + {post?.author?.showname || "匿名用户"} + + + | + {/* Date Info Badge */} + + + + + 创建于: + {dayjs(post?.createdAt).format("YYYY-MM-DD")} + + + | + {/* Last Updated Badge */} + + + + 更新于: + {dayjs(post?.updatedAt).format("YYYY-MM-DD")} + + + | + {/* Visibility Status Badge */} + + {post?.isPublic ? ( + + ) : ( + + )} + + {post?.isPublic ? "公开" : "私信"} + + +
+ {/* Second Row - Term and Tags */} + {post?.meta?.tags?.length > 0 && ( +
+ {/* Tags Badges */} + {post.meta.tags.length > 0 && + post.meta.tags.map((tag, index) => ( + + + + ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/models/post/detail/PostHeader/PostHeader.tsx b/apps/web/src/components/models/post/detail/PostHeader/PostHeader.tsx new file mode 100644 index 0000000..da12fb9 --- /dev/null +++ b/apps/web/src/components/models/post/detail/PostHeader/PostHeader.tsx @@ -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 ( + <> +
+ + + ); +} diff --git a/apps/web/src/components/models/post/detail/PostHeader/TitleSection.tsx b/apps/web/src/components/models/post/detail/PostHeader/TitleSection.tsx deleted file mode 100644 index 4de55f6..0000000 --- a/apps/web/src/components/models/post/detail/PostHeader/TitleSection.tsx +++ /dev/null @@ -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 ( - - {/* Decorative Line */} - {/*
*/} - - {/* Title */} -

- {post?.title} -

- - - - - - {/* */} - - ); -} diff --git a/apps/web/src/components/models/post/detail/PostResources.tsx b/apps/web/src/components/models/post/detail/PostResources.tsx new file mode 100644 index 0000000..77daa43 --- /dev/null +++ b/apps/web/src/components/models/post/detail/PostResources.tsx @@ -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 ( +
+ {resources?.map((resource) => ( +
+ {isImage(resource.url) ? ( + <> + {resource.title} + + ) : ( + + )} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/models/post/detail/layout/PostDetailLayout.tsx b/apps/web/src/components/models/post/detail/layout/PostDetailLayout.tsx index 21fecd8..676dd62 100644 --- a/apps/web/src/components/models/post/detail/layout/PostDetailLayout.tsx +++ b/apps/web/src/components/models/post/detail/layout/PostDetailLayout.tsx @@ -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"; 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 da2469c..dba7d18 100644 --- a/apps/web/src/components/models/post/editor/context/LetterEditorContext.tsx +++ b/apps/web/src/components/models/post/editor/context/LetterEditorContext.tsx @@ -9,6 +9,8 @@ export interface LetterFormData { title: string; content: string; resources?: string[]; + receivers?: string[]; + terms?: string[]; isPublic?: boolean; signature?: string; meta: { @@ -43,24 +45,38 @@ export function LetterFormProvider({ const onSubmit = async (data: LetterFormData) => { try { console.log("data", data); + const receivers = data?.receivers; + const terms = data?.terms; + delete data.receivers; + delete data.terms; const result = await create.mutateAsync({ data: { + ...data, type: PostType.POST, - termId: termId, - receivers: { - connect: [receiverId].filter(Boolean).map((id) => ({ + + terms: { + connect: (terms || [])?.filter(Boolean).map((id) => ({ id, })), }, - state: PostState.PENDING, - isPublic: data?.isPublic, - ...data, - resources: data.resources?.length - ? { - connect: data.resources.map((id) => ({ + receivers: { + connect: (receivers || []) + ?.filter(Boolean) + .map((id) => ({ id, })), - } + }, + state: PostState.PENDING, + isPublic: data?.isPublic, + + resources: data.resources?.length + ? { + connect: ( + data.resources?.filter(Boolean) || [] + ).map((fileId) => ({ + fileId, + })), + } : undefined, }, }); diff --git a/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx b/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx index debf3d2..3b34b2e 100644 --- a/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx +++ b/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx @@ -2,6 +2,8 @@ import { Form, Input, Button, Checkbox, Select } from "antd"; import { useLetterEditor } from "../context/LetterEditorContext"; import { SendOutlined } from "@ant-design/icons"; 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 TermSelect from "../../../term/term-select"; @@ -13,30 +15,30 @@ export function LetterBasicForm() { return (
+ initialValues={{ + meta: { tags: [] }, + receiverId, + termId, + isPublic: true, + }}>
- + - +
+ rules={[{ required: true, message: "请输入信件标题" }]}> @@ -46,7 +48,9 @@ export function LetterBasicForm() { mode="tags" placeholder="输入标签后按回车添加" value={form.getFieldValue(["meta", "tags"]) || []} - onChange={(value) => form.setFieldValue(["meta", "tags"], value)} + onChange={(value) => + form.setFieldValue(["meta", "tags"], value) + } tokenSeparators={[",", " "]} className="w-full" dropdownStyle={{ display: "none" }} @@ -55,8 +59,7 @@ export function LetterBasicForm() { {label} + onClick={onClose}> ×
@@ -68,26 +71,32 @@ export function LetterBasicForm() { + required={false}>
form.setFieldValue("content", content)} - + onChange={(content) => + form.setFieldValue("content", content) + } + /> +
+
+ +
+ + form.setFieldValue("resources", resources) + } />
- {/* Footer Actions */}
是否公开 @@ -98,8 +107,7 @@ export function LetterBasicForm() { onClick={() => form.submit()} size="large" icon={} - className="w-full sm:w-40" - > + className="w-full sm:w-40"> 发送信件
diff --git a/apps/web/src/hooks/useTusUpload.ts b/apps/web/src/hooks/useTusUpload.ts index 6b3924b..1ef2c66 100644 --- a/apps/web/src/hooks/useTusUpload.ts +++ b/apps/web/src/hooks/useTusUpload.ts @@ -26,10 +26,19 @@ export function useTusUpload() { onSuccess: (result: UploadResult) => 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); setProgress(0); setUploadError(null); - const upload = new tus.Upload(file, { + + try { + const upload = new tus.Upload(file, { endpoint: "http://localhost:3000/upload", retryDelays: [0, 1000, 3000, 5000], metadata: { @@ -73,7 +82,14 @@ export function useTusUpload() { }, }); upload.start(); + } catch (error) { + const err = error instanceof Error ? error : new Error("Upload failed"); + setIsUploading(false); + setUploadError(err.message); + onError(err); + } }; + return { progress, isUploading, diff --git a/config/nginx/conf.d/web.conf b/config/nginx/conf.d/web.conf index bf90259..361e802 100644 --- a/config/nginx/conf.d/web.conf +++ b/config/nginx/conf.d/web.conf @@ -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; # 私有缓存,禁止转换 diff --git a/packages/common/prisma/schema.prisma b/packages/common/prisma/schema.prisma index 4a3a901..fe37e9e 100644 --- a/packages/common/prisma/schema.prisma +++ b/packages/common/prisma/schema.prisma @@ -27,7 +27,8 @@ model Taxonomy { model Term { id String @id @default(cuid()) name String - posts Post[] + // posts Post[] + posts Post[] @relation("post_term") taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id]) taxonomyId String? @map("taxonomy_id") order Float? @map("order") @@ -193,8 +194,10 @@ model Post { content String? // 帖子内容,可为空 domainId String? @map("domain_id") - term Term? @relation(fields: [termId], references: [id]) - termId String? @map("term_id") + // term Term? @relation(fields: [termId], references: [id]) + // termId String? @map("term_id") + // 添加多对多关系 + terms Term[] @relation("post_term") // 日期时间类型字段 createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @map("updated_at") diff --git a/packages/common/src/select.ts b/packages/common/src/select.ts index 8208654..668bd06 100644 --- a/packages/common/src/select.ts +++ b/packages/common/src/select.ts @@ -12,16 +12,10 @@ export const postDetailSelect: Prisma.PostSelect = { resources: true, createdAt: true, updatedAt: true, - termId: true, - term: { + + terms: { include: { - taxonomy: { - select: { - id: true, - slug: true, - name: true, - }, - }, + }, }, authorId: true,