From 26cc1e064e232209ec98f0d26e0c9e1eebcc9b8d Mon Sep 17 00:00:00 2001 From: ditiqi Date: Sat, 25 Jan 2025 02:28:28 +0800 Subject: [PATCH] add0125-0228 --- .../common/uploader/TusUploader.tsx | 199 +++++++++++++++++- .../models/post/detail/PostCommentEditor.tsx | 12 +- .../editor/context/LetterEditorContext.tsx | 13 +- .../post/editor/form/LetterBasicForm.tsx | 24 ++- apps/web/src/hooks/useTusUpload.ts | 18 +- packages/common/prisma/schema.prisma | 9 +- packages/common/src/select.ts | 12 +- 7 files changed, 263 insertions(+), 24 deletions(-) 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/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); + }}>
({ + id, + })), + }, receivers: { connect: [receiverId].filter(Boolean).map((id) => ({ id, @@ -57,8 +62,10 @@ export function LetterFormProvider({ ...data, resources: data.resources?.length ? { - connect: data.resources.map((id) => ({ - id, + 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 f5327b7..0edcefc 100644 --- a/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx +++ b/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx @@ -11,6 +11,7 @@ import { 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"; export function LetterBasicForm() { const { onSubmit, receiverId, termId, form } = useLetterEditor(); @@ -132,8 +133,27 @@ export function LetterBasicForm() {
- - + {/* 内容输入框 */} +
+ + + 附件 + + } + name="resources" + labelCol={{ span: 24 }} + wrapperCol={{ span: 24 }}> +
+ + form.setFieldValue("resources", resources) + } + /> +
+
+
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/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,