diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py index e7b8c85..fc0cb67 100644 --- a/backend/app/routers/documents.py +++ b/backend/app/routers/documents.py @@ -56,10 +56,12 @@ def delete_document(key: str, service: DocumentService = Depends(get_document_se async def upload_document( file: UploadFile = File(...), path: str = Form(...), + relative_path: str | None = Form(None), service: DocumentService = Depends(get_document_service), ): data = await file.read() - key = f"{path.rstrip('/')}/{file.filename}" if path else file.filename + name = relative_path if relative_path else file.filename + key = f"{path.rstrip('/')}/{name}" if path else name content_type = file.content_type or "application/octet-stream" try: return service.upload_document(key, data, content_type) diff --git a/backend/tests/test_routers_documents.py b/backend/tests/test_routers_documents.py index ea7408d..7aa5dd4 100644 --- a/backend/tests/test_routers_documents.py +++ b/backend/tests/test_routers_documents.py @@ -114,6 +114,17 @@ def test_upload_document_returns_201_with_metadata(client): assert body["size"] == len(b"# new content") +def test_upload_document_uses_relative_path_to_preserve_folder_structure(client): + response = client.post( + "/api/upload", + data={"path": "产品文档", "relative_path": "子目录/新文档.md"}, + files={"file": ("新文档.md", b"# new content", "text/markdown")}, + ) + assert response.status_code == 201 + body = response.json() + assert body["key"] == "产品文档/子目录/新文档.md" + + def test_upload_document_returns_400_for_unsupported_file_type(client): response = client.post( "/api/upload", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 43f9db7..6f1e9a9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -77,10 +77,17 @@ export interface UploadDocumentResponse { size: number; } -export async function uploadDocument(file: File, path: string): Promise { +export async function uploadDocument( + file: File, + path: string, + relativePath?: string, +): Promise { const formData = new FormData(); formData.append('file', file); formData.append('path', path); + if (relativePath) { + formData.append('relative_path', relativePath); + } const response = await fetch(`${API_BASE_URL}/api/upload`, { method: 'POST', body: formData, diff --git a/frontend/src/components/UploadButton.tsx b/frontend/src/components/UploadButton.tsx index c2483e5..aca9d9d 100644 --- a/frontend/src/components/UploadButton.tsx +++ b/frontend/src/components/UploadButton.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from 'react'; import { uploadDocument, type UploadDocumentResponse } from '../api/client'; +import { readFileEntries, type FileWithRelativePath } from '../lib/fileSystemEntry'; interface UploadButtonProps { path: string; @@ -11,6 +12,14 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) { const [isDragOver, setIsDragOver] = useState(false); const [uploadError, setUploadError] = useState(null); const fileInputRef = useRef(null); + const folderInputRef = useRef(null); + + function setFolderInputRef(node: HTMLInputElement | null) { + folderInputRef.current = node; + if (node) { + node.setAttribute('webkitdirectory', ''); + } + } function openModal() { setIsOpen(true); @@ -22,17 +31,22 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) { setUploadError(null); } + function uploadOne(entry: FileWithRelativePath) { + setUploadError(null); + const promise = entry.relativePath + ? uploadDocument(entry.file, path, entry.relativePath) + : uploadDocument(entry.file, path); + promise + .then((result) => { + onUploaded(result); + }) + .catch((err) => { + setUploadError(err instanceof Error ? err.message : '上传失败'); + }); + } + function uploadFiles(files: FileList | File[]) { - Array.from(files).forEach((file) => { - setUploadError(null); - uploadDocument(file, path) - .then((result) => { - onUploaded(result); - }) - .catch((err) => { - setUploadError(err instanceof Error ? err.message : '上传失败'); - }); - }); + Array.from(files).forEach((file) => uploadOne({ file })); } function handleFileInputChange(event: React.ChangeEvent) { @@ -42,9 +56,24 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) { event.target.value = ''; } - function handleDrop(event: React.DragEvent) { + function handleFolderInputChange(event: React.ChangeEvent) { + if (event.target.files) { + Array.from(event.target.files).forEach((file) => + uploadOne({ file, relativePath: file.webkitRelativePath || file.name }), + ); + } + event.target.value = ''; + } + + async function handleDrop(event: React.DragEvent) { event.preventDefault(); setIsDragOver(false); + const items = event.dataTransfer.items; + if (items && items.length > 0 && typeof items[0]?.webkitGetAsEntry === 'function') { + const entries = await readFileEntries(items); + entries.forEach(uploadOne); + return; + } if (event.dataTransfer.files) { uploadFiles(event.dataTransfer.files); } @@ -95,7 +124,7 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) { - 拖拽文件到此处,或点击选择 + 拖拽文件或文件夹到此处,或点击选择
支持 .md .html 及常见图片格式
{uploadError && ( @@ -111,8 +140,18 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) { style={{ display: 'none' }} onChange={handleFileInputChange} /> +
+ diff --git a/frontend/src/lib/fileSystemEntry.ts b/frontend/src/lib/fileSystemEntry.ts new file mode 100644 index 0000000..c25bba9 --- /dev/null +++ b/frontend/src/lib/fileSystemEntry.ts @@ -0,0 +1,52 @@ +export interface FileWithRelativePath { + file: File; + relativePath?: string; +} + +function readEntries(reader: FileSystemDirectoryReader): Promise { + return new Promise((resolve, reject) => { + reader.readEntries(resolve, reject); + }); +} + +async function collectAllEntries(reader: FileSystemDirectoryReader): Promise { + const all: FileSystemEntry[] = []; + let batch = await readEntries(reader); + while (batch.length > 0) { + all.push(...batch); + batch = await readEntries(reader); + } + return all; +} + +function entryToFile(entry: FileSystemFileEntry): Promise { + return new Promise((resolve, reject) => { + entry.file(resolve, reject); + }); +} + +async function walkEntry(entry: FileSystemEntry, results: FileWithRelativePath[]): Promise { + if (entry.isFile) { + const file = await entryToFile(entry as FileSystemFileEntry); + results.push({ file, relativePath: entry.fullPath.replace(/^\//, '') }); + return; + } + if (entry.isDirectory) { + const reader = (entry as FileSystemDirectoryEntry).createReader(); + const children = await collectAllEntries(reader); + for (const child of children) { + await walkEntry(child, results); + } + } +} + +export async function readFileEntries(items: DataTransferItemList): Promise { + const results: FileWithRelativePath[] = []; + const entries = Array.from(items) + .map((item) => item.webkitGetAsEntry()) + .filter((entry): entry is FileSystemEntry => entry !== null); + for (const entry of entries) { + await walkEntry(entry, results); + } + return results; +} diff --git a/frontend/tests/api/client.test.ts b/frontend/tests/api/client.test.ts index feb62a3..e5dce8d 100644 --- a/frontend/tests/api/client.test.ts +++ b/frontend/tests/api/client.test.ts @@ -90,9 +90,25 @@ test('uploadDocument posts a multipart form with file and path fields', async () const body = init.body as FormData; expect(body.get('file')).toBe(file); expect(body.get('path')).toBe('产品文档'); + expect(body.get('relative_path')).toBeNull(); expect(result).toEqual(mockResponse); }); +test('uploadDocument includes relative_path when uploading a file from a folder', async () => { + const mockResponse = { key: '产品文档/子目录/新文档.md', name: '新文档.md', size: 42 }; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) as unknown as typeof fetch; + const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' }); + + await uploadDocument(file, '产品文档', '子目录/新文档.md'); + + const [, init] = (globalThis.fetch as ReturnType).mock.calls[0]; + const body = init.body as FormData; + expect(body.get('relative_path')).toBe('子目录/新文档.md'); +}); + test('search calls GET /api/search with the q query param and returns parsed JSON', async () => { const mockResponse = { results: [ diff --git a/frontend/tests/components/UploadButton.test.tsx b/frontend/tests/components/UploadButton.test.tsx index 5dcd3c0..2b5f3bc 100644 --- a/frontend/tests/components/UploadButton.test.tsx +++ b/frontend/tests/components/UploadButton.test.tsx @@ -58,6 +58,25 @@ test('a failed upload shows an error message and keeps the modal open for retry' expect(onUploaded).not.toHaveBeenCalled(); }); +test('selecting a folder through the folder input uploads each file with its relative path', async () => { + vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/子目录/新文档.md', name: '新文档.md', size: 8 }); + const onUploaded = vi.fn(); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: '上传文档' })); + + const file = new File(['content'], '新文档.md', { type: 'text/markdown' }); + Object.defineProperty(file, 'webkitRelativePath', { value: '子目录/新文档.md', configurable: true }); + const input = screen.getByTestId('upload-folder-input'); + await user.upload(input, file); + + expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档', '子目录/新文档.md'); + await waitFor(() => { + expect(onUploaded).toHaveBeenCalled(); + }); +}); + test('dropping a file onto the dropzone uploads it', async () => { vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 }); const onUploaded = vi.fn(); @@ -78,3 +97,46 @@ test('dropping a file onto the dropzone uploads it', async () => { expect(onUploaded).toHaveBeenCalledWith({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 }); }); }); + +test('dropping a folder onto the dropzone walks its entries and uploads each file with its relative path', async () => { + vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/子目录/文件.md', name: '文件.md', size: 5 }); + const onUploaded = vi.fn(); + const user = userEvent.setup(); + const file = new File(['# hi'], '文件.md', { type: 'text/markdown' }); + + const fileEntry = { + isFile: true, + isDirectory: false, + fullPath: '/子目录/文件.md', + file: (success: (f: File) => void) => success(file), + }; + let readCount = 0; + const dirReader = { + readEntries: (success: (entries: unknown[]) => void) => { + readCount += 1; + success(readCount === 1 ? [fileEntry] : []); + }, + }; + const dirEntry = { + isFile: false, + isDirectory: true, + fullPath: '/子目录', + createReader: () => dirReader, + }; + const item = { webkitGetAsEntry: () => dirEntry }; + + render(); + await user.click(screen.getByRole('button', { name: '上传文档' })); + const dropzone = screen.getByTestId('dropzone'); + + fireEvent.drop(dropzone, { + dataTransfer: { items: [item], files: [] }, + }); + + await waitFor(() => { + expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档', '子目录/文件.md'); + }); + await waitFor(() => { + expect(onUploaded).toHaveBeenCalled(); + }); +});