feat: support uploading entire folders
Adds a folder picker (webkitdirectory) and drag-and-drop directory support (File System Entries API), preserving relative paths via a new optional relative_path field on the upload endpoint.
This commit is contained in:
parent
4c19fad561
commit
4a6fdaf994
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -77,10 +77,17 @@ export interface UploadDocumentResponse {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function uploadDocument(file: File, path: string): Promise<UploadDocumentResponse> {
|
||||
export async function uploadDocument(
|
||||
file: File,
|
||||
path: string,
|
||||
relativePath?: string,
|
||||
): Promise<UploadDocumentResponse> {
|
||||
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,
|
||||
|
||||
@ -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<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement | null>(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 uploadFiles(files: FileList | File[]) {
|
||||
Array.from(files).forEach((file) => {
|
||||
function uploadOne(entry: FileWithRelativePath) {
|
||||
setUploadError(null);
|
||||
uploadDocument(file, path)
|
||||
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) => uploadOne({ file }));
|
||||
}
|
||||
|
||||
function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
@ -42,9 +56,24 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
function handleDrop(event: React.DragEvent<HTMLDivElement>) {
|
||||
function handleFolderInputChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
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<HTMLDivElement>) {
|
||||
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) {
|
||||
<path d="M6 9l6-6 6 6" />
|
||||
<path d="M4 21h16" />
|
||||
</svg>
|
||||
拖拽文件到此处,或点击选择
|
||||
拖拽文件或文件夹到此处,或点击选择
|
||||
<div className="hint">支持 .md .html 及常见图片格式</div>
|
||||
</div>
|
||||
{uploadError && (
|
||||
@ -111,8 +140,18 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<input
|
||||
ref={setFolderInputRef}
|
||||
type="file"
|
||||
data-testid="upload-folder-input"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFolderInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn-secondary" onClick={() => folderInputRef.current?.click()}>
|
||||
选择文件夹
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={closeModal}>
|
||||
取消
|
||||
</button>
|
||||
|
||||
52
frontend/src/lib/fileSystemEntry.ts
Normal file
52
frontend/src/lib/fileSystemEntry.ts
Normal file
@ -0,0 +1,52 @@
|
||||
export interface FileWithRelativePath {
|
||||
file: File;
|
||||
relativePath?: string;
|
||||
}
|
||||
|
||||
function readEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function collectAllEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
|
||||
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<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
entry.file(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function walkEntry(entry: FileSystemEntry, results: FileWithRelativePath[]): Promise<void> {
|
||||
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<FileWithRelativePath[]> {
|
||||
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;
|
||||
}
|
||||
@ -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<typeof vi.fn>).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: [
|
||||
|
||||
@ -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(<UploadButton path="产品文档" onUploaded={onUploaded} />);
|
||||
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(<UploadButton path="产品文档" onUploaded={onUploaded} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user