Compare commits

...

10 Commits

Author SHA1 Message Date
4a6fdaf994 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.
2026-08-01 16:30:29 +08:00
4c19fad561 refactor: replace Garage/S3 storage with local filesystem storage
Single-node deployment doesn't benefit from S3 abstraction overhead;
LocalFileClient keeps the same interface DocumentService depends on,
backed by a STORAGE_DIR volume mount instead of a separate Garage service.
2026-08-01 16:30:02 +08:00
d2d046802b chore: stop tracking storage/, moved out as standalone garage deployment
Garage storage is now managed independently at ../dochub-storage,
decoupled from this repo to avoid confusion with the app stack.
2026-08-01 14:25:18 +08:00
360e9e5a14 refactor: rename all minio references to garage, drop minio SDK for boto3
The backend no longer mentions MinIO anywhere: MinioClient -> GarageClient
(minio_client.py -> garage_client.py), MINIO_* env vars -> GARAGE_*, and
the `minio` Python package is replaced with `boto3` (AWS's generic S3
client) since Garage is the only storage backend this project targets
now. S3 API semantics are unchanged, so document_service.py's behavior
is identical — this is a naming and dependency swap, not a logic change.

Also updates test fixtures/names (FakeMinioClient -> FakeGarageClient,
FakeMinioSDK -> FakeS3SDK) and storage/README.md, which no longer frames
Garage as a MinIO replacement (that migration note has served its purpose
now that no MinIO reference remains in the codebase).

Backend: 54/54 tests passing. Frontend: 38/38 tests passing (unaffected,
verified for regressions since it talks to the backend only through the
unchanged REST API).
2026-08-01 10:51:49 +08:00
0f0b7e9d32 feat: add Garage as self-hosted S3-compatible storage, replacing MinIO
MinIO's community edition has been trimming features and its licensing
direction has grown less predictable. Garage (AGPL, purpose-built for
self-hosting) is a drop-in S3-compatible replacement — no changes needed
to backend/app/minio_client.py, which talks to the S3 API generically.

Deployed independently from the app stack (storage/docker-compose.yml),
same separation MinIO had. Single-node config (replication_factor = 1)
matching this project's learning/small-team scope. init-garage.sh handles
the one-time layout assignment + bucket creation Garage requires that
MinIO didn't (MinIO auto-creates buckets via API; Garage needs an
explicit `garage bucket create` step).
2026-08-01 09:43:08 +08:00
b13810d68b feat: add docker-compose.yml for backend + frontend + meilisearch
MinIO stays out of this compose file per the design spec — it's deployed
independently, with MINIO_ENDPOINT overridable via env for wherever it
actually lives. Meilisearch uses the official image directly (no
Dockerfile needed); frontend's VITE_API_BASE_URL is left empty since
nginx.conf already proxies /api/ to the backend service.
2026-08-01 09:25:11 +08:00
a6b04c1538 chore: stop tracking docs/ (spec and plan are local-only artifacts) 2026-08-01 09:20:18 +08:00
9ef22f9f0a Fix wave 2: CORS default, path traversal, delete UI, shared clients, bucket check
- Default CORS_ORIGINS to http://localhost:5173 instead of "*" (still overridable)
- Reject ".." and leading "/" in document keys/paths across get/save/delete/upload,
  mapped to HTTP 400 via new InvalidPathError
- Add a delete button to DocumentEditor wired to deleteDocument + onDeleted,
  giving the existing DELETE API an actual UI entry point
- Share a single lru_cache'd Meilisearch (and Minio) SDK client instance instead
  of constructing duplicates in dependencies.py
- Add a startup check that creates the MinIO bucket if missing, tolerating
  MinIO being unreachable at boot without crashing the app
2026-08-01 09:15:16 +08:00
911d814a2f feat: wire approved archive-room prototype styling into React components
Adds frontend/src/styles.css extracted from the confirmed prototype
(paper/ink/pine palette, Fraunces/Inter/IBM Plex Mono type system,
catalog-tag motif) and makes the minimal JSX adjustments needed for
each component to pick up the styling: wordmark heading, search icon,
upload button icon + modal header/footer, folder chevrons + file
icons, doc-path + save-status in the editor, catalog tags and
no-results treatment in search results.

Also adds .gitignore (venv/node_modules/dist/pycache) and commits the
frontend package-lock.json that was previously untracked.
2026-08-01 09:05:44 +08:00
7e218b5738 fix: repair build, preserve unsaved edits on save failure, fix search/upload UX gaps
- client.test.ts: use globalThis.fetch instead of global.fetch and drop the
  unused describe import, fixing the broken `npm run build`
- DocumentEditor: separate load-error (replaces view) from save-error (shown
  inline, editor and unsaved content stay mounted so a failed save no longer
  destroys in-progress edits); expose dirty state via onDirtyChange
- App: prompt via window.confirm before switching documents while dirty;
  increment a refreshKey on successful upload so FileExplorer re-fetches the
  tree; track isSearching so the "no results" overlay doesn't flash during
  the search debounce window
- FileExplorer: accept a refreshKey prop to force a root-listing re-fetch
- SearchBar: convert to a controlled component (value prop) so App can clear
  the input after jumping to a search result, fixing input/state desync
- UploadButton: surface upload failures inline instead of swallowing the
  rejected promise, and keep the modal open for retry
2026-08-01 08:59:49 +08:00
37 changed files with 5341 additions and 4096 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# Python
__pycache__/
*.pyc
.venv/
venv/
# Node
node_modules/
dist/
# Editor
.DS_Store
# Docs
docs/

View File

@ -3,14 +3,11 @@ import os
class Settings:
def __init__(self):
self.minio_endpoint = os.environ.get("MINIO_ENDPOINT", "localhost:9000")
self.minio_access_key = os.environ.get("MINIO_ACCESS_KEY", "minioadmin")
self.minio_secret_key = os.environ.get("MINIO_SECRET_KEY", "minioadmin")
self.minio_bucket = os.environ.get("MINIO_BUCKET", "dochub")
self.storage_dir = os.environ.get("STORAGE_DIR", "/data/documents")
self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700")
self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "")
self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents")
self.cors_origins = os.environ.get("CORS_ORIGINS", "*").split(",")
self.cors_origins = os.environ.get("CORS_ORIGINS", "http://localhost:5173").split(",")
settings = Settings()

View File

@ -1,29 +1,30 @@
from functools import lru_cache
import meilisearch
from minio import Minio
from app.config import settings
from app.document_service import DocumentService
from app.minio_client import MinioClient
from app.local_file_client import LocalFileClient
from app.search_client import SearchClient
@lru_cache
def _get_file_client() -> LocalFileClient:
return LocalFileClient(settings.storage_dir)
@lru_cache
def _get_meilisearch_sdk() -> meilisearch.Client:
return meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
@lru_cache
def get_document_service() -> DocumentService:
minio_sdk = Minio(
settings.minio_endpoint,
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=False,
)
meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
minio_client = MinioClient(minio_sdk, settings.minio_bucket)
search_client = SearchClient(meili_sdk, settings.meilisearch_index)
return DocumentService(minio_client, search_client)
file_client = _get_file_client()
search_client = SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index)
return DocumentService(file_client, search_client)
@lru_cache
def get_search_client() -> SearchClient:
meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
return SearchClient(meili_sdk, settings.meilisearch_index)
return SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index)

View File

@ -26,6 +26,17 @@ class FileTooLargeError(Exception):
super().__init__(f"file too large: max {max_size_bytes // (1024 * 1024)}MB")
class InvalidPathError(Exception):
def __init__(self, path: str):
self.path = path
super().__init__(f"invalid path: {path}")
def _validate_path(path: str) -> None:
if path.startswith("/") or any(segment == ".." for segment in path.split("/")):
raise InvalidPathError(path)
def _extension_of(key: str) -> str:
name = key.rsplit("/", 1)[-1]
if "." not in name:
@ -42,13 +53,13 @@ def _guess_content_type(key: str) -> str:
class DocumentService:
def __init__(self, minio_client, search_client):
self._minio_client = minio_client
def __init__(self, file_client, search_client):
self._file_client = file_client
self._search_client = search_client
def list_documents(self, path: str) -> dict:
prefix = path if not path or path.endswith("/") else f"{path}/"
entries = self._minio_client.list_objects(prefix)
entries = self._file_client.list_objects(prefix)
folders = []
files = []
for entry in entries:
@ -68,8 +79,9 @@ class DocumentService:
return {"folders": folders, "files": files}
def get_document(self, key: str) -> dict:
_validate_path(key)
try:
data = self._minio_client.get_object(key)
data = self._file_client.get_object(key)
except FileNotFoundError as exc:
raise DocumentNotFoundError(key) from exc
try:
@ -94,7 +106,8 @@ class DocumentService:
return text
def save_document(self, key: str, content: bytes, content_type: str) -> dict:
self._minio_client.put_object(key, content, content_type)
_validate_path(key)
self._file_client.put_object(key, content, content_type)
try:
text = self._extract_text(content, content_type)
title = key.rsplit("/", 1)[-1]
@ -105,13 +118,15 @@ class DocumentService:
return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()}
def delete_document(self, key: str) -> None:
self._minio_client.delete_object(key)
_validate_path(key)
self._file_client.delete_object(key)
try:
self._search_client.delete_document(key)
except Exception:
logger.exception("Failed to delete search index entry for %s", key)
def upload_document(self, key: str, data: bytes, content_type: str) -> dict:
_validate_path(key)
extension = _extension_of(key)
if extension not in ALLOWED_UPLOAD_EXTENSIONS:
raise UnsupportedFileTypeError(extension)

View File

@ -0,0 +1,76 @@
import os
from pathlib import Path
from datetime import datetime, timezone
class LocalFileClient:
"""本地文件系统存储客户端"""
def __init__(self, base_dir: str):
self._base_dir = Path(base_dir).resolve()
self._base_dir.mkdir(parents=True, exist_ok=True)
def _resolve_path(self, key: str) -> Path:
"""将 key 转换为文件系统路径,确保不会逃出 base_dir"""
path = (self._base_dir / key).resolve()
if not path.is_relative_to(self._base_dir):
raise ValueError(f"Path traversal attempt: {key}")
return path
def list_objects(self, prefix: str) -> list[dict]:
"""列出指定前缀下的所有对象(文件和目录)"""
prefix_path = self._base_dir / prefix if prefix else self._base_dir
if not prefix_path.exists():
return []
result = []
try:
for entry in prefix_path.iterdir():
rel_path = entry.relative_to(self._base_dir)
key = str(rel_path).replace(os.sep, "/")
if entry.is_dir():
result.append({
"key": key + "/",
"size": None,
"is_dir": True,
"modifiedAt": None,
})
else:
stat = entry.stat()
result.append({
"key": key,
"size": stat.st_size,
"is_dir": False,
"modifiedAt": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
})
except (PermissionError, OSError):
pass
return result
def get_object(self, key: str) -> bytes:
path = self._resolve_path(key)
if not path.exists() or not path.is_file():
raise FileNotFoundError(key)
return path.read_bytes()
def put_object(self, key: str, data: bytes, content_type: str) -> None:
path = self._resolve_path(key)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
def delete_object(self, key: str) -> None:
path = self._resolve_path(key)
if not path.exists():
raise FileNotFoundError(key)
path.unlink()
# 清理空目录(逐层向上)
parent = path.parent
while parent != self._base_dir:
if not any(parent.iterdir()):
parent.rmdir()
parent = parent.parent
else:
break

View File

@ -1,9 +1,13 @@
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import documents, search
logger = logging.getLogger(__name__)
app = FastAPI(title="DocHub API")
app.add_middleware(

View File

@ -1,45 +0,0 @@
import io
from minio.error import S3Error
class MinioClient:
def __init__(self, client, bucket: str):
self._client = client
self._bucket = bucket
def list_objects(self, prefix: str) -> list[dict]:
objects = self._client.list_objects(self._bucket, prefix=prefix, recursive=False)
result = []
for obj in objects:
result.append({
"key": obj.object_name,
"size": obj.size,
"is_dir": obj.object_name.endswith("/"),
"modifiedAt": obj.last_modified.isoformat() if obj.last_modified else None,
})
return result
def get_object(self, key: str) -> bytes:
try:
response = self._client.get_object(self._bucket, key)
except S3Error as exc:
if exc.code == "NoSuchKey":
raise FileNotFoundError(key) from exc
raise
try:
return response.read()
finally:
response.close()
response.release_conn()
def put_object(self, key: str, data: bytes, content_type: str) -> None:
self._client.put_object(
self._bucket,
key,
io.BytesIO(data),
length=len(data),
content_type=content_type,
)
def delete_object(self, key: str) -> None:
self._client.remove_object(self._bucket, key)

View File

@ -6,6 +6,7 @@ from app.document_service import (
DocumentNotFoundError,
DocumentService,
FileTooLargeError,
InvalidPathError,
UnsupportedFileTypeError,
)
@ -28,18 +29,26 @@ def get_document(key: str, service: DocumentService = Depends(get_document_servi
return service.get_document(key)
except DocumentNotFoundError:
raise HTTPException(status_code=404, detail="Document not found")
except InvalidPathError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.put("/api/documents/{key:path}")
def save_document(
key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service)
):
try:
return service.save_document(key, body.content.encode("utf-8"), body.contentType)
except InvalidPathError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.delete("/api/documents/{key:path}", status_code=204)
def delete_document(key: str, service: DocumentService = Depends(get_document_service)):
try:
service.delete_document(key)
except InvalidPathError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return None
@ -47,12 +56,14 @@ 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)
except (UnsupportedFileTypeError, FileTooLargeError) as exc:
except (UnsupportedFileTypeError, FileTooLargeError, InvalidPathError) as exc:
raise HTTPException(status_code=400, detail=str(exc))

View File

@ -1,6 +1,5 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
minio==7.2.9
meilisearch==0.31.5
pytest==8.3.3
httpx==0.27.2

View File

@ -0,0 +1,17 @@
from app.config import Settings
def test_cors_origins_defaults_to_localhost_dev_port(monkeypatch):
monkeypatch.delenv("CORS_ORIGINS", raising=False)
settings = Settings()
assert settings.cors_origins == ["http://localhost:5173"]
def test_cors_origins_can_be_overridden_to_wildcard(monkeypatch):
monkeypatch.setenv("CORS_ORIGINS", "*")
settings = Settings()
assert settings.cors_origins == ["*"]

View File

@ -4,12 +4,13 @@ from app.document_service import (
DocumentService,
DocumentNotFoundError,
FileTooLargeError,
InvalidPathError,
MAX_UPLOAD_SIZE_BYTES,
UnsupportedFileTypeError,
)
class FakeMinioClient:
class FakeFileClient:
def __init__(self):
self.entries = []
self.stored = {}
@ -54,13 +55,13 @@ class FakeSearchClient:
def test_list_documents_at_root_returns_folders_and_files():
minio_client = FakeMinioClient()
minio_client.entries = [
file_client = FakeFileClient()
file_client.entries = [
{"key": "产品文档/", "is_dir": True, "size": 0, "modifiedAt": None},
{"key": "团队规范/", "is_dir": True, "size": 0, "modifiedAt": None},
{"key": "README.md", "is_dir": False, "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"},
]
service = DocumentService(minio_client, FakeSearchClient())
service = DocumentService(file_client, FakeSearchClient())
result = service.list_documents("")
@ -71,9 +72,9 @@ def test_list_documents_at_root_returns_folders_and_files():
def test_get_document_returns_content_and_content_type():
minio_client = FakeMinioClient()
minio_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 MinIO 存储文档。".encode("utf-8")
service = DocumentService(minio_client, FakeSearchClient())
file_client = FakeFileClient()
file_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 Garage 存储文档。".encode("utf-8")
service = DocumentService(file_client, FakeSearchClient())
result = service.get_document("产品文档/架构设计.md")
@ -83,17 +84,17 @@ def test_get_document_returns_content_and_content_type():
def test_get_document_raises_not_found_for_missing_key():
service = DocumentService(FakeMinioClient(), FakeSearchClient())
service = DocumentService(FakeFileClient(), FakeSearchClient())
with pytest.raises(DocumentNotFoundError):
service.get_document("不存在/文档.md")
def test_get_document_returns_base64_content_for_binary_data_without_raising():
minio_client = FakeMinioClient()
file_client = FakeFileClient()
binary_data = b"\x89PNG\r\n\x1a\n\x00\x01\xff\xfe"
minio_client.stored["产品文档/图片.png"] = binary_data
service = DocumentService(minio_client, FakeSearchClient())
file_client.stored["产品文档/图片.png"] = binary_data
service = DocumentService(file_client, FakeSearchClient())
result = service.get_document("产品文档/图片.png")
@ -103,105 +104,139 @@ def test_get_document_returns_base64_content_for_binary_data_without_raising():
def test_get_document_returns_utf8_encoding_for_text_content():
minio_client = FakeMinioClient()
minio_client.stored["产品文档/架构设计.md"] = "# 架构设计".encode("utf-8")
service = DocumentService(minio_client, FakeSearchClient())
file_client = FakeFileClient()
file_client.stored["产品文档/架构设计.md"] = "# 架构设计".encode("utf-8")
service = DocumentService(file_client, FakeSearchClient())
result = service.get_document("产品文档/架构设计.md")
assert result["encoding"] == "utf-8"
def test_save_document_writes_to_minio_then_indexes_content():
minio_client = FakeMinioClient()
def test_save_document_writes_to_garage_then_indexes_content():
file_client = FakeFileClient()
search_client = FakeSearchClient()
service = DocumentService(minio_client, search_client)
content = "# 架构设计\n\nDocHub 使用 **MinIO** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8")
service = DocumentService(file_client, search_client)
content = "# 架构设计\n\nDocHub 使用 **Garage** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8")
result = service.save_document("产品文档/架构设计.md", content, "text/markdown")
assert minio_client.stored["产品文档/架构设计.md"] == content
assert file_client.stored["产品文档/架构设计.md"] == content
assert search_client.indexed[0]["key"] == "产品文档/架构设计.md"
assert search_client.indexed[0]["path"] == "产品文档"
assert search_client.indexed[0]["title"] == "架构设计.md"
assert "MinIO" in search_client.indexed[0]["content"]
assert "Garage" in search_client.indexed[0]["content"]
assert "#" not in search_client.indexed[0]["content"]
assert "key" in result and "updatedAt" in result
def test_save_document_does_not_fail_when_index_update_fails():
minio_client = FakeMinioClient()
file_client = FakeFileClient()
search_client = FakeSearchClient(fail_on_index=True)
service = DocumentService(minio_client, search_client)
service = DocumentService(file_client, search_client)
content = b"# doc"
result = service.save_document("产品文档/架构设计.md", content, "text/markdown")
assert minio_client.stored["产品文档/架构设计.md"] == content
assert file_client.stored["产品文档/架构设计.md"] == content
assert result["key"] == "产品文档/架构设计.md"
def test_delete_document_removes_from_minio_and_search_index():
minio_client = FakeMinioClient()
minio_client.stored["产品文档/旧文档.md"] = b"old content"
def test_delete_document_removes_from_garage_and_search_index():
file_client = FakeFileClient()
file_client.stored["产品文档/旧文档.md"] = b"old content"
search_client = FakeSearchClient()
service = DocumentService(minio_client, search_client)
service = DocumentService(file_client, search_client)
service.delete_document("产品文档/旧文档.md")
assert "产品文档/旧文档.md" not in minio_client.stored
assert "产品文档/旧文档.md" not in file_client.stored
assert search_client.deleted == ["产品文档/旧文档.md"]
def test_delete_document_does_not_fail_when_index_delete_fails():
minio_client = FakeMinioClient()
minio_client.stored["产品文档/旧文档.md"] = b"old content"
file_client = FakeFileClient()
file_client.stored["产品文档/旧文档.md"] = b"old content"
search_client = FakeSearchClient(fail_on_delete=True)
service = DocumentService(minio_client, search_client)
service = DocumentService(file_client, search_client)
service.delete_document("产品文档/旧文档.md")
assert "产品文档/旧文档.md" not in minio_client.stored
assert "产品文档/旧文档.md" not in file_client.stored
def test_upload_document_writes_file_and_returns_metadata():
minio_client = FakeMinioClient()
service = DocumentService(minio_client, FakeSearchClient())
file_client = FakeFileClient()
service = DocumentService(file_client, FakeSearchClient())
data = b"# uploaded doc"
result = service.upload_document("产品文档/新上传.md", data, "text/markdown")
assert result == {"key": "产品文档/新上传.md", "name": "新上传.md", "size": len(data)}
assert minio_client.stored["产品文档/新上传.md"] == data
assert file_client.stored["产品文档/新上传.md"] == data
def test_upload_document_rejects_unsupported_file_extension():
minio_client = FakeMinioClient()
service = DocumentService(minio_client, FakeSearchClient())
file_client = FakeFileClient()
service = DocumentService(file_client, FakeSearchClient())
with pytest.raises(UnsupportedFileTypeError):
service.upload_document("产品文档/病毒.exe", b"data", "application/octet-stream")
assert "产品文档/病毒.exe" not in minio_client.stored
assert "产品文档/病毒.exe" not in file_client.stored
def test_upload_document_rejects_oversized_file():
minio_client = FakeMinioClient()
service = DocumentService(minio_client, FakeSearchClient())
file_client = FakeFileClient()
service = DocumentService(file_client, FakeSearchClient())
data = b"x" * (MAX_UPLOAD_SIZE_BYTES + 1)
with pytest.raises(FileTooLargeError):
service.upload_document("产品文档/大文件.md", data, "text/markdown")
assert "产品文档/大文件.md" not in minio_client.stored
assert "产品文档/大文件.md" not in file_client.stored
def test_get_document_rejects_path_traversal_key():
service = DocumentService(FakeFileClient(), FakeSearchClient())
with pytest.raises(InvalidPathError):
service.get_document("../../etc/passwd")
def test_save_document_rejects_path_traversal_key():
file_client = FakeFileClient()
service = DocumentService(file_client, FakeSearchClient())
with pytest.raises(InvalidPathError):
service.save_document("../../etc/passwd", b"data", "text/plain")
assert "../../etc/passwd" not in file_client.stored
def test_delete_document_rejects_path_traversal_key():
service = DocumentService(FakeFileClient(), FakeSearchClient())
with pytest.raises(InvalidPathError):
service.delete_document("../../etc/passwd")
def test_upload_document_rejects_path_traversal_key():
file_client = FakeFileClient()
service = DocumentService(file_client, FakeSearchClient())
with pytest.raises(InvalidPathError):
service.upload_document("../../etc/passwd", b"data", "text/plain")
assert "../../etc/passwd" not in file_client.stored
def test_upload_document_accepts_allowed_image_extension():
minio_client = FakeMinioClient()
service = DocumentService(minio_client, FakeSearchClient())
file_client = FakeFileClient()
service = DocumentService(file_client, FakeSearchClient())
data = b"\x89PNG\r\n\x1a\n"
result = service.upload_document("产品文档/图片.png", data, "image/png")
assert result == {"key": "产品文档/图片.png", "name": "图片.png", "size": len(data)}
assert minio_client.stored["产品文档/图片.png"] == data
assert file_client.stored["产品文档/图片.png"] == data

View File

@ -0,0 +1,54 @@
import pytest
from app.local_file_client import LocalFileClient
@pytest.fixture
def client(tmp_path):
return LocalFileClient(str(tmp_path))
def test_put_and_get_object_roundtrip(client):
client.put_object("产品文档/架构设计.md", b"# hi", "text/markdown")
assert client.get_object("产品文档/架构设计.md") == b"# hi"
def test_get_object_raises_file_not_found_for_missing_key(client):
with pytest.raises(FileNotFoundError):
client.get_object("不存在/文档.md")
def test_delete_object_removes_file(client):
client.put_object("产品文档/旧文档.md", b"old", "text/markdown")
client.delete_object("产品文档/旧文档.md")
with pytest.raises(FileNotFoundError):
client.get_object("产品文档/旧文档.md")
def test_delete_object_raises_file_not_found_for_missing_key(client):
with pytest.raises(FileNotFoundError):
client.delete_object("不存在/文档.md")
def test_list_objects_returns_files_and_dirs_at_prefix(client):
client.put_object("产品文档/架构设计.md", b"# hi", "text/markdown")
client.put_object("团队规范/编码规范.md", b"# rules", "text/markdown")
result = client.list_objects("产品文档/")
assert len(result) == 1
assert result[0]["key"] == "产品文档/架构设计.md"
assert result[0]["size"] == 4
assert result[0]["is_dir"] is False
def test_list_objects_returns_empty_list_for_missing_prefix(client):
assert client.list_objects("不存在/") == []
def test_resolve_path_rejects_path_traversal(client):
with pytest.raises(ValueError):
client.get_object("../../etc/passwd")

View File

@ -1,108 +0,0 @@
from datetime import datetime, timezone
import pytest
from minio.error import S3Error
from app.minio_client import MinioClient
class _FakeObject:
def __init__(self, object_name, size, last_modified):
self.object_name = object_name
self.size = size
self.last_modified = last_modified
class _FakeResponse:
def __init__(self, data: bytes):
self._data = data
self.closed = False
self.released = False
def read(self):
return self._data
def close(self):
self.closed = True
def release_conn(self):
self.released = True
class FakeMinioSDK:
def __init__(self):
self.objects = {}
self.put_calls = []
self.removed_keys = []
def list_objects(self, bucket, prefix="", recursive=False):
for key, (data, content_type, last_modified) in self.objects.items():
if key.startswith(prefix):
yield _FakeObject(key, len(data), last_modified)
def get_object(self, bucket, key):
if key not in self.objects:
raise S3Error("NoSuchKey", "Object does not exist", key, "req-1", "host-1", None)
data, _, _ = self.objects[key]
return _FakeResponse(data)
def put_object(self, bucket, key, stream, length, content_type):
data = stream.read()
self.objects[key] = (data, content_type, datetime.now(timezone.utc))
self.put_calls.append((bucket, key, content_type))
def remove_object(self, bucket, key):
del self.objects[key]
self.removed_keys.append(key)
def test_list_objects_returns_keys_matching_prefix():
sdk = FakeMinioSDK()
sdk.objects["产品文档/架构设计.md"] = (b"# hi", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc))
sdk.objects["团队规范/编码规范.md"] = (b"# rules", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc))
client = MinioClient(sdk, bucket="dochub")
result = client.list_objects("产品文档/")
assert len(result) == 1
assert result[0]["key"] == "产品文档/架构设计.md"
assert result[0]["size"] == 4
def test_get_object_returns_bytes():
sdk = FakeMinioSDK()
sdk.objects["产品文档/架构设计.md"] = (b"# architecture", "text/markdown", datetime.now(timezone.utc))
client = MinioClient(sdk, bucket="dochub")
data = client.get_object("产品文档/架构设计.md")
assert data == b"# architecture"
def test_get_object_raises_file_not_found_for_missing_key():
sdk = FakeMinioSDK()
client = MinioClient(sdk, bucket="dochub")
with pytest.raises(FileNotFoundError):
client.get_object("不存在/文档.md")
def test_put_object_stores_data_with_content_type():
sdk = FakeMinioSDK()
client = MinioClient(sdk, bucket="dochub")
client.put_object("产品文档/新文档.md", b"# new doc", "text/markdown")
assert sdk.objects["产品文档/新文档.md"][0] == b"# new doc"
assert sdk.put_calls == [("dochub", "产品文档/新文档.md", "text/markdown")]
def test_delete_object_removes_key():
sdk = FakeMinioSDK()
sdk.objects["产品文档/旧文档.md"] = (b"old", "text/markdown", datetime.now(timezone.utc))
client = MinioClient(sdk, bucket="dochub")
client.delete_object("产品文档/旧文档.md")
assert "产品文档/旧文档.md" not in sdk.objects
assert sdk.removed_keys == ["产品文档/旧文档.md"]

View File

@ -3,7 +3,12 @@ from fastapi.testclient import TestClient
from app.main import app
from app.dependencies import get_document_service
from app.document_service import DocumentNotFoundError, FileTooLargeError, UnsupportedFileTypeError
from app.document_service import (
DocumentNotFoundError,
FileTooLargeError,
InvalidPathError,
UnsupportedFileTypeError,
)
class FakeDocumentService:
@ -23,16 +28,24 @@ class FakeDocumentService:
def get_document(self, key):
if key == "不存在/文档.md":
raise DocumentNotFoundError(key)
if ".." in key.split("/"):
raise InvalidPathError(key)
return {"key": key, "content": "# 架构设计\n\n内容", "contentType": "text/markdown"}
def save_document(self, key, content, content_type):
if ".." in key.split("/"):
raise InvalidPathError(key)
self.saved.append((key, content, content_type))
return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"}
def delete_document(self, key):
if ".." in key.split("/"):
raise InvalidPathError(key)
self.deleted.append(key)
def upload_document(self, key, data, content_type):
if ".." in key.split("/"):
raise InvalidPathError(key)
if key.endswith(".exe"):
raise UnsupportedFileTypeError(".exe")
if len(data) > 1024:
@ -101,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",
@ -119,3 +143,30 @@ def test_upload_document_returns_400_for_oversized_file(client):
)
assert response.status_code == 400
assert "file too large" in response.json()["detail"]
def test_get_document_returns_400_for_path_traversal_key(client):
response = client.get("/api/documents/..%2F..%2Fetc%2Fpasswd")
assert response.status_code == 400
def test_put_document_returns_400_for_path_traversal_key(client):
response = client.put(
"/api/documents/..%2F..%2Fetc%2Fpasswd",
json={"content": "x", "contentType": "text/plain"},
)
assert response.status_code == 400
def test_delete_document_returns_400_for_path_traversal_key(client):
response = client.delete("/api/documents/..%2F..%2Fetc%2Fpasswd")
assert response.status_code == 400
def test_upload_document_returns_400_for_path_traversal_in_path(client):
response = client.post(
"/api/upload",
data={"path": "../../etc"},
files={"file": ("passwd.md", b"data", "text/markdown")},
)
assert response.status_code == 400

View File

@ -48,5 +48,5 @@ def test_search_returns_empty_results_for_no_match(client):
def test_search_passes_query_string_to_search_client(client):
client.get("/api/search", params={"q": "MinIO"})
assert client.fake_client.queries == ["MinIO"]
client.get("/api/search", params={"q": "Garage"})
assert client.fake_client.queries == ["Garage"]

View File

@ -40,7 +40,7 @@ def test_index_document_adds_document_with_encoded_id():
client = SearchClient(sdk, index_name="documents")
client.index_document(
"产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 MinIO 存储文档"
"产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 Garage 存储文档"
)
index = sdk.indexes["documents"]
@ -49,7 +49,7 @@ def test_index_document_adds_document_with_encoded_id():
assert stored["key"] == "产品文档/架构设计.md"
assert stored["path"] == "产品文档"
assert stored["title"] == "架构设计.md"
assert stored["content"] == "DocHub 使用 MinIO 存储文档"
assert stored["content"] == "DocHub 使用 Garage 存储文档"
def test_delete_document_removes_document():

View File

@ -6,7 +6,7 @@ def test_html_to_text_strips_tags():
<html>
<body>
<h1>架构设计</h1>
<p>DocHub 是一个基于 <strong>MinIO</strong> 的文档管理系统</p>
<p>DocHub 是一个基于 <strong>Garage</strong> 的文档管理系统</p>
<ul>
<li>浏览文档</li>
<li>全文检索</li>
@ -16,7 +16,7 @@ def test_html_to_text_strips_tags():
"""
result = html_to_text(html)
assert "架构设计" in result
assert "DocHub 是一个基于 MinIO 的文档管理系统。" in result
assert "DocHub 是一个基于 Garage 的文档管理系统。" in result
assert "浏览文档" in result
assert "<h1>" not in result
assert "<strong>" not in result
@ -33,7 +33,7 @@ def test_markdown_to_text_strips_headers_and_emphasis():
## 背景
DocHub 是一个 **基于 MinIO** _文档管理系统_支持全文检索
DocHub 是一个 **基于 Garage** _文档管理系统_支持全文检索
"""
result = markdown_to_text(markdown)
assert "#" not in result
@ -41,21 +41,21 @@ DocHub 是一个 **基于 MinIO** 的 _文档管理系统_支持全文检索
assert "_" not in result
assert "架构设计" in result
assert "背景" in result
assert "基于 MinIO" in result
assert "基于 Garage" in result
assert "文档管理系统" in result
def test_markdown_to_text_strips_links_and_list_markers():
markdown = """## 功能列表
- 浏览 [MinIO](https://min.io) 中存储的文档
- 浏览 [Garage](https://garagehq.deuxfleurs.fr) 中存储的文档
- 支持 [Meilisearch](https://www.meilisearch.com) 全文检索
1. 在线编辑并保存
"""
result = markdown_to_text(markdown)
assert "[" not in result
assert "](" not in result
assert "浏览 MinIO 中存储的文档" in result
assert "浏览 Garage 中存储的文档" in result
assert "支持 Meilisearch 全文检索" in result
assert "在线编辑并保存" in result

37
docker-compose.yml Normal file
View File

@ -0,0 +1,37 @@
services:
backend:
build: ./backend
environment:
STORAGE_DIR: /data/documents
MEILISEARCH_HOST: http://meilisearch:7700
MEILISEARCH_API_KEY: ${MEILISEARCH_API_KEY:-}
MEILISEARCH_INDEX: ${MEILISEARCH_INDEX:-documents}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080}
volumes:
- ${STORAGE_HOST_DIR:-../dochub-data}:/data/documents
depends_on:
- meilisearch
restart: unless-stopped
frontend:
build:
context: ./frontend
args:
VITE_API_BASE_URL: ""
ports:
- "8080:80"
depends_on:
- backend
restart: unless-stopped
meilisearch:
image: getmeili/meilisearch:v1.10
environment:
MEILI_MASTER_KEY: ${MEILISEARCH_API_KEY:-}
MEILI_NO_ANALYTICS: "true"
volumes:
- meilisearch-data:/meili_data
restart: unless-stopped
volumes:
meilisearch-data:

File diff suppressed because it is too large Load Diff

View File

@ -1,153 +0,0 @@
# DocHub 设计文档
## 背景与目标
小团队10 人以下)需要一个基于 MinIO 的文档管理系统,支持:
- 浏览 MinIO 中存储的文档(主要是 Markdown / HTML图片为次要内容
- 全文内容检索(不只是文件名搜索)
- 在线编辑文档内容并保存
- 上传新文档
无需用户登录和权限管理(内部工具,信任团队内所有成员)。
## 技术栈
| 层 | 选型 | 理由 |
|----|------|------|
| 前端 | React + Vite + Monaco Editor | Monaco 提供代码级编辑体验(语法高亮、查找替换),适合 Markdown/HTML 源码编辑 |
| 后端 | Python + FastAPI | 团队熟悉度高,异步 IO 足够应对 MinIO/Meilisearch 网络调用瓶颈生态利于后续扩展文档解析、AI 摘要等) |
| 存储 | MinIO独立部署 | S3 兼容对象存储,文档内容和元数据(大小/时间/路径)都存在这里,不引入额外数据库 |
| 检索 | Meilisearch | 轻量、内置中文分词、MIT 协议、Docker 一条命令部署,检索延迟低 |
明确不引入的组件:
- 关系型数据库(内容和元数据用 MinIO 承载,索引用 Meilisearch 承载,当前需求下够用)
- 用户认证/权限系统(团队内部工具,暂不需要)
## 架构
```
┌─────────────┐
HTTPS │ nginx │
───────────▶ │ (反向代理) │
└──────┬──────┘
┌────────────┼────────────┐
▼ ▼ ▼
app.domain api.domain minio.domain(可选)
│ │
┌───────▼───────┐ ┌──▼──────────────┐
│ React 前端 │ │ FastAPI 后端 │
│ Vite + Monaco│ │ (Python) │
└───────────────┘ └───┬──────────┬───┘
│ │
S3 API │ │ REST API
▼ ▼
┌─────────────┐ ┌──────────────┐
│ MinIO │ │ Meilisearch │
│ (独立部署) │ │ (与后端同机) │
└─────────────┘ └──────────────┘
```
**部署形态**:两套独立的 docker-compose各自独立生命周期
- `minio/docker-compose.yml` — 仅 MinIO独立服务器或独立栈长期稳定运行可被其他项目复用
- `app/docker-compose.yml` — frontend + backend + meilisearch可整体重启/升级而不影响已存储的数据
两套 compose 之间通过共享 Docker 网络或宿主机 IP 通信(后端配置 MinIO endpoint 即可,代码不感知部署形态差异,因为走的是标准 S3 API
**域名与端口**:通过 nginx 反向代理 + 子域名区分服务,无需在域名上绑定端口:
```
app.domain → React 前端
api.domain → FastAPI 后端
minio.domain → MinIO 控制台(可选对外)
```
Meilisearch 仅供后端内网访问,不对外暴露子域名。
## 数据流
**上传文档**
```
前端上传文件 → 后端 PUT 到 MinIO
→ 后端解析内容Markdown/HTML 转纯文本)
→ 推送索引到 Meilisearch
→ 返回成功
```
**浏览文件列表**
```
前端请求文件树 → 后端调用 MinIO ListObjects → 返回文件夹/文件结构
```
**查看/编辑文档**
```
点击文件 → 后端从 MinIO GetObject 取内容 → 前端 Monaco Editor 加载展示
保存编辑 → 后端覆盖写入 MinIO → 同步更新 Meilisearch 索引
```
**全局搜索**
```
输入关键词 → 后端转发查询到 Meilisearch
→ 返回匹配文档(含高亮片段)
→ 前端展示结果列表,点击跳转到对应文档
```
**删除文档**
```
后端从 MinIO 删除对象 → 同步从 Meilisearch 删除对应索引
```
**关键决策:索引更新采用后端同步处理**,即写完 MinIO 后立即在同一请求中更新 Meilisearch 索引,不引入 MinIO webhook 异步机制。理由:团队规模小、写入量低,同步更新逻辑更简单、更容易保证一致性,避免维护额外的 webhook 消费者服务。
## 模块设计
### 前端React + Vite + Monaco Editor
| 组件 | 职责 |
|------|------|
| `FileExplorer` | 左侧文件树,展示文件夹/文件,支持点击导航 |
| `DocumentViewer/Editor` | 主区域,用 Monaco 加载文档内容,支持编辑与保存 |
| `SearchBar` | 顶部全局搜索框输入即查debounce |
| `SearchResults` | 搜索结果列表,展示高亮匹配片段,点击跳转对应文档 |
| `UploadButton` | 拖拽或选择文件上传 |
| `apiClient` | 封装对后端的 fetch 调用documents CRUD + search是前端与后端之间的唯一边界 |
### 后端FastAPI
API 层:
| 接口 | 方法 | 职责 |
|------|------|------|
| `/api/documents` | GET | 列出文件树(基于 MinIO ListObjects |
| `/api/documents/{key}` | GET | 读取单个文档内容 |
| `/api/documents/{key}` | PUT | 创建/覆盖文档(写 MinIO + 更新索引) |
| `/api/documents/{key}` | DELETE | 删除文档(删 MinIO + 删索引) |
| `/api/upload` | POST | 处理文件上传multipart |
| `/api/search` | GET | 转发查询到 Meilisearch返回结果 |
内部模块划分(各模块单一职责,可独立测试):
- `minio_client.py` — 封装 MinIO 操作list/get/put/delete只负责与 MinIO 交互,不含业务逻辑
- `search_client.py` — 封装 Meilisearch 操作index/update/delete/search只负责与 Meilisearch 交互
- `document_service.py` — 业务逻辑层,协调 `minio_client``search_client`(保存文档先写 MinIO 再更新索引;删除时顺序相反),并负责 Markdown/HTML 转纯文本供索引使用
- `routes/documents.py`、`routes/search.py` — API 路由层,只做参数校验和调用 service不直接触碰 MinIO/Meilisearch 客户端
依赖方向:`routes` → `document_service``minio_client` / `search_client`。上层不了解下层实现细节,替换 MinIO 为其他 S3 兼容存储或替换 Meilisearch 为其他搜索引擎时,只需改动对应的 client 模块。
## 错误处理
- MinIO 写入成功但索引更新失败:记录日志,不阻塞用户操作(文档已保存成功),后续可手动触发全量重建索引
- 请求的文档不存在:返回 404
- 上传文件类型/大小校验:在后端 `document_service` 层做校验(限制非文档类型或超大文件),拒绝时返回明确错误信息
## 测试策略
- 后端pytest 测试 `document_service` 业务逻辑mock `minio_client``search_client`,覆盖保存/删除时序、索引失败降级等场景
- 前端:暂不强制要求自动化测试覆盖(小型内部工具,手动验证优先级更高)
## 部署资源参考
学习/小团队场景下DocHub 各服务的大致资源占用(供部署规划参考,非强制要求):
- MinIO约 256-512MB 内存
- Meilisearch约 512MB-1GB 内存(随索引文档量增长)
- FastAPI 后端:约 256-512MB 内存
- React/nginx 前端:约 128-256MB 内存
4 核 / 4GB 内存足以运行 DocHub 全部服务;若与其他自建服务(如 Gitea、数据库共享同一台机器建议预留合计 8GB 以上内存。

3559
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -10,37 +10,85 @@ export default function App() {
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [uploadPath] = useState('');
function confirmDiscardIfDirty(): boolean {
if (!isDirty) {
return true;
}
return window.confirm('有未保存的修改,确定要离开吗?');
}
function handleSelectFile(key: string) {
if (!confirmDiscardIfDirty()) {
return;
}
setSelectedKey(key);
}
function handleJumpToResult(key: string) {
if (!confirmDiscardIfDirty()) {
return;
}
setSelectedKey(key);
setSearchQuery('');
setSearchResults([]);
setIsSearching(false);
}
function handleQueryChange(query: string) {
setSearchQuery(query);
setIsSearching(query.trim() !== '');
}
function handleResults(results: SearchResult[]) {
setSearchResults(results);
setIsSearching(false);
}
function handleUploaded(result: UploadDocumentResponse) {
setSelectedKey(result.key);
setRefreshKey((prev) => prev + 1);
}
function handleDeleted() {
setSelectedKey(null);
setRefreshKey((prev) => prev + 1);
}
return (
<div className="app">
<div className="topbar">
<h1>DocHub</h1>
<SearchBar onQueryChange={setSearchQuery} onResults={setSearchResults} />
<UploadButton path="" onUploaded={handleUploaded} />
<h1 className="wordmark" aria-label="DocHub">
DocHub <span className="subtitle"> · </span>
</h1>
<SearchBar value={searchQuery} onQueryChange={handleQueryChange} onResults={handleResults} />
<UploadButton path={uploadPath} onUploaded={handleUploaded} />
</div>
<div className="body">
<FileExplorer onSelectFile={setSelectedKey} selectedKey={selectedKey} />
<FileExplorer onSelectFile={handleSelectFile} selectedKey={selectedKey} refreshKey={refreshKey} />
<div className="main" data-testid="main-area">
{selectedKey ? (
<DocumentEditor documentKey={selectedKey} />
<DocumentEditor documentKey={selectedKey} onDirtyChange={setIsDirty} onDeleted={handleDeleted} />
) : (
<div data-testid="empty-state"></div>
<div className="empty-state" data-testid="empty-state">
</div>
)}
</div>
{searchQuery !== '' && (
{searchQuery !== '' && !isSearching && (
<div className="search-overlay">
<SearchResults query={searchQuery} results={searchResults} onJumpToResult={handleJumpToResult} />
</div>
)}
</div>
<div className="statusbar">
<span>DOCHUB</span>
<span>UTF-8</span>
</div>
</div>
);
}

View File

@ -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,

View File

@ -1,22 +1,28 @@
import { useEffect, useState } from 'react';
import Editor from '@monaco-editor/react';
import { getDocument, saveDocument } from '../api/client';
import { deleteDocument, getDocument, saveDocument } from '../api/client';
interface DocumentEditorProps {
documentKey: string;
onDirtyChange?: (isDirty: boolean) => void;
onDeleted: () => void;
}
export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }: DocumentEditorProps) {
const [content, setContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [contentType, setContentType] = useState('text/markdown');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
setLoadError(null);
setSaveError(null);
getDocument(documentKey)
.then((doc) => {
setContent(doc.content);
@ -25,47 +31,98 @@ export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
setLoading(false);
})
.catch(() => {
setError('无法加载文档');
setLoadError('无法加载文档');
setLoading(false);
});
}, [documentKey]);
const isDirty = content !== originalContent;
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
function handleChange(value: string | undefined) {
setContent(value ?? '');
}
function handleSave() {
setSaving(true);
setSaveError(null);
saveDocument(documentKey, content, contentType)
.then(() => {
setOriginalContent(content);
setSaving(false);
})
.catch(() => {
setError('保存失败');
setSaveError('保存失败,请重试');
setSaving(false);
});
}
function handleDelete() {
if (!window.confirm('确定要删除这篇文档吗?此操作无法撤销。')) {
return;
}
setDeleting(true);
setDeleteError(null);
deleteDocument(documentKey)
.then(() => {
setDeleting(false);
onDeleted();
})
.catch(() => {
setDeleteError('删除失败,请重试');
setDeleting(false);
});
}
if (loading) {
return <div data-testid="document-editor-loading"></div>;
}
if (error) {
return <div data-testid="document-editor-error">{error}</div>;
if (loadError) {
return <div data-testid="document-editor-error">{loadError}</div>;
}
return (
<div data-testid="document-editor">
<div className="doc-header">
<span data-testid="save-status">{isDirty ? '未保存' : '已归档'}</span>
<button onClick={handleSave} disabled={!isDirty || saving}>
<div className="doc-path">
<span className="current">{documentKey}</span>
</div>
<div className="doc-actions">
<span className={isDirty ? 'save-status dirty' : 'save-status saved'} data-testid="save-status">
<span className="dot" />
{isDirty ? '未保存' : '已归档'}
</span>
{saveError && (
<span className="save-error" data-testid="save-error">
{saveError}
<button onClick={() => setSaveError(null)} aria-label="关闭错误提示">
×
</button>
</span>
)}
{deleteError && (
<span className="save-error" data-testid="delete-error">
{deleteError}
<button onClick={() => setDeleteError(null)} aria-label="关闭错误提示">
×
</button>
</span>
)}
<button className="btn-secondary" onClick={handleDelete} disabled={deleting}>
{deleting ? '删除中…' : '删除'}
</button>
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
<div className="editor-area">
<Editor value={content} onChange={handleChange} language={contentType === 'text/markdown' ? 'markdown' : 'html'} />
</div>
</div>
);
}

View File

@ -4,9 +4,10 @@ import { listDocuments, type DocumentFile } from '../api/client';
interface FileExplorerProps {
onSelectFile: (key: string) => void;
selectedKey?: string | null;
refreshKey?: number;
}
export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorerProps) {
export default function FileExplorer({ onSelectFile, selectedKey, refreshKey }: FileExplorerProps) {
const [folders, setFolders] = useState<string[]>([]);
const [files, setFiles] = useState<DocumentFile[]>([]);
const [error, setError] = useState<string | null>(null);
@ -18,7 +19,7 @@ export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorer
setFiles(data.files);
})
.catch(() => setError('无法加载文档目录'));
}, []);
}, [refreshKey]);
if (error) {
return <div data-testid="file-explorer-error">{error}</div>;
@ -48,10 +49,14 @@ function FileRow({
return (
<div
data-testid={`file-${file.key}`}
className={selectedKey === file.key ? 'file-row active' : 'file-row'}
className={selectedKey === file.key ? 'tree-item file-row active' : 'tree-item file-row'}
onClick={() => onSelectFile(file.key)}
>
{file.name}
<svg className="file-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<path d="M14 2v6h6" />
</svg>
<span className="filename">{file.name}</span>
</div>
);
}
@ -85,7 +90,12 @@ function FolderNode({
return (
<div>
<div data-testid={`folder-${path}`} className="folder" onClick={toggle}>
<div data-testid={`folder-${path}`} className="tree-item folder" onClick={toggle}>
<span className={expanded ? 'chevron open' : 'chevron'}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</span>
{name}
</div>
{expanded && (

View File

@ -1,14 +1,14 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import { search, type SearchResult } from '../api/client';
interface SearchBarProps {
value: string;
onQueryChange: (query: string) => void;
onResults: (results: SearchResult[]) => void;
debounceMs?: number;
}
export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) {
const [value, setValue] = useState('');
export default function SearchBar({ value, onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
@ -21,7 +21,6 @@ export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const nextValue = event.target.value;
setValue(nextValue);
onQueryChange(nextValue);
if (timerRef.current) {
@ -41,6 +40,11 @@ export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }
}
return (
<div className="search-box">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
placeholder="检索全部文档内容…"
@ -48,5 +52,6 @@ export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }
onChange={handleChange}
aria-label="搜索文档"
/>
</div>
);
}

View File

@ -8,20 +8,34 @@ interface SearchResultsProps {
export default function SearchResults({ results, query, onJumpToResult }: SearchResultsProps) {
if (results.length === 0) {
return <div data-testid="search-no-results"></div>;
return (
<div className="no-results" data-testid="search-no-results">
<span className="serif"></span>
</div>
);
}
return (
<div data-testid="search-results">
<div data-testid="search-results-count">
{results.length} {query}
<div className="search-results-header" data-testid="search-results-count">
<span className="big-count">{results.length}</span> {query}
</div>
{results.map((result) => (
<div key={result.key} data-testid={`search-result-${result.key}`} onClick={() => onJumpToResult(result.key)}>
<div className="result-title">{result.title}</div>
<div className="result-path">{result.path}</div>
<div
key={result.key}
className="result-item"
data-testid={`search-result-${result.key}`}
onClick={() => onJumpToResult(result.key)}
>
<span className="catalog-tag">{result.path}</span>
<div className="result-body">
<div className="result-path">
<span className="result-title">{result.title}</span>
</div>
<div className="result-snippet" dangerouslySetInnerHTML={{ __html: result.snippet }} />
</div>
</div>
))}
</div>
);

View File

@ -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;
@ -9,7 +10,16 @@ interface UploadButtonProps {
export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
const [isOpen, setIsOpen] = useState(false);
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);
@ -18,14 +28,25 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
function closeModal() {
setIsOpen(false);
setIsDragOver(false);
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) => {
uploadDocument(file, path).then((result) => {
onUploaded(result);
});
});
Array.from(files).forEach((file) => uploadOne({ file }));
}
function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) {
@ -35,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);
}
@ -54,10 +90,27 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
return (
<>
<button onClick={openModal}></button>
<button className="upload-btn" onClick={openModal}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 3v12" />
<path d="M6 9l6-6 6 6" />
<path d="M4 21h16" />
</svg>
</button>
{isOpen && (
<div className="modal-backdrop show" data-testid="upload-modal">
<div className="modal">
<div className="modal-header">
<h3></h3>
<button className="modal-close" onClick={closeModal} aria-label="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="modal-body">
<div
className={isDragOver ? 'dropzone dragover' : 'dropzone'}
data-testid="dropzone"
@ -66,8 +119,19 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M12 3v12" />
<path d="M6 9l6-6 6 6" />
<path d="M4 21h16" />
</svg>
<div className="hint"> .md .html </div>
</div>
{uploadError && (
<div className="upload-error" data-testid="upload-error">
{uploadError}
</div>
)}
<input
ref={fileInputRef}
type="file"
@ -76,7 +140,22 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
style={{ display: 'none' }}
onChange={handleFileInputChange}
/>
<button onClick={closeModal}></button>
<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>
</div>
</div>
</div>
)}

View 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;
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>

618
frontend/src/styles.css Normal file
View File

@ -0,0 +1,618 @@
:root {
--paper: #faf9f6;
--paper-alt: #f1efe6;
--ink: #1b1b18;
--ash: #86847c;
--ash-faint: #b3b0a5;
--rule: #e4e1d8;
--pine: #2f5d50;
--pine-deep: #234840;
--pine-soft: #e7efec;
--amber: #e8a33d;
--amber-soft: #fbead0;
--radius: 6px;
--shadow: 0 12px 36px rgba(27, 27, 24, 0.1);
}
* {
box-sizing: border-box;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: 'Inter', -apple-system, 'PingFang SC', sans-serif;
background: var(--paper);
color: var(--ink);
font-size: 13px;
}
.serif {
font-family: 'Fraunces', serif;
}
.mono {
font-family: 'IBM Plex Mono', Consolas, monospace;
}
.catalog-tag {
font-family: 'IBM Plex Mono', monospace;
font-size: 10.5px;
color: var(--pine);
background: var(--pine-soft);
border: 1px solid rgba(47, 93, 80, 0.18);
border-radius: 4px;
padding: 1px 6px;
white-space: nowrap;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.app {
height: 100vh;
display: flex;
flex-direction: column;
}
/* ---------- Top bar ---------- */
.topbar {
height: 58px;
background: var(--paper);
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
padding: 0 22px;
gap: 22px;
flex-shrink: 0;
}
.wordmark {
font-family: 'Fraunces', serif;
font-style: italic;
font-weight: 600;
font-size: 19px;
color: var(--ink);
white-space: nowrap;
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
}
.wordmark .subtitle {
font-family: 'IBM Plex Mono', monospace;
font-style: normal;
font-size: 10px;
color: var(--ash);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.search-box {
flex: 1;
max-width: 540px;
position: relative;
}
.search-box input {
width: 100%;
background: var(--paper-alt);
border: 1px solid var(--rule);
border-radius: var(--radius);
color: var(--ink);
padding: 9px 14px 9px 38px;
font-size: 13px;
font-family: 'Inter', sans-serif;
outline: none;
transition: border-color 0.15s, background 0.15s;
}
.search-box input::placeholder {
color: var(--ash-faint);
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
}
.search-box input:focus {
border-color: var(--pine);
background: var(--paper);
box-shadow: 0 0 0 3px var(--pine-soft);
}
.search-box svg {
position: absolute;
left: 13px;
top: 50%;
transform: translateY(-50%);
color: var(--ash);
pointer-events: none;
}
.upload-btn {
margin-left: auto;
background: var(--pine);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 9px 16px;
font-size: 12.5px;
font-weight: 500;
font-family: 'Inter', sans-serif;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 7px;
transition: background 0.15s;
}
.upload-btn:hover {
background: var(--pine-deep);
}
/* ---------- Body layout ---------- */
.body {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
}
/* ---------- Sidebar ---------- */
.sidebar {
width: 272px;
background: var(--paper-alt);
border-right: 1px solid var(--rule);
overflow-y: auto;
padding: 16px 10px;
flex-shrink: 0;
}
.sidebar-title {
font-family: 'Fraunces', serif;
font-size: 13px;
font-weight: 600;
color: var(--ink);
padding: 4px 10px 12px;
display: flex;
align-items: baseline;
justify-content: space-between;
}
.sidebar-title .count {
font-family: 'IBM Plex Mono', monospace;
color: var(--ash);
font-size: 11px;
font-weight: 400;
}
.tree-item {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 10px;
font-size: 13px;
cursor: pointer;
color: var(--ink);
white-space: nowrap;
border-radius: 5px;
user-select: none;
}
.tree-item:hover {
background: rgba(27, 27, 24, 0.045);
}
.tree-item.file-row.active {
background: #fff;
box-shadow: 0 0 0 1px var(--rule);
font-weight: 500;
}
.tree-item.folder {
color: var(--ash);
font-weight: 500;
font-size: 11.5px;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.tree-children {
overflow: hidden;
}
.chevron {
display: inline-flex;
transition: transform 0.15s ease;
color: var(--ash-faint);
flex-shrink: 0;
}
.chevron.open {
transform: rotate(90deg);
}
.file-row {
padding-left: 20px;
}
.file-row .filename {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.file-icon {
color: var(--ash);
flex-shrink: 0;
}
/* ---------- Main ---------- */
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--ash-faint);
font-family: 'Fraunces', serif;
font-size: 15px;
}
.doc-header {
height: 54px;
background: var(--paper);
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
padding: 0 24px;
gap: 12px;
flex-shrink: 0;
}
.doc-path {
color: var(--ash);
font-size: 12.5px;
display: flex;
align-items: center;
gap: 6px;
}
.doc-path .sep {
color: var(--ash-faint);
}
.doc-path .current {
color: var(--ink);
font-weight: 500;
}
.doc-actions {
margin-left: auto;
display: flex;
gap: 8px;
align-items: center;
}
.save-status {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: var(--ash-faint);
margin-right: 6px;
display: flex;
align-items: center;
gap: 5px;
}
.save-status .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ash-faint);
}
.save-status.dirty {
color: #a9762a;
}
.save-status.dirty .dot {
background: var(--amber);
}
.save-status.saved {
color: var(--pine);
}
.save-status.saved .dot {
background: var(--pine);
}
.save-error {
font-size: 11.5px;
color: #a9401f;
display: flex;
align-items: center;
gap: 4px;
}
.save-error button {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 13px;
line-height: 1;
padding: 0;
}
.btn-save {
background: var(--pine);
color: #fff;
border: none;
border-radius: 5px;
padding: 7px 15px;
font-size: 12px;
font-weight: 500;
font-family: 'Inter', sans-serif;
cursor: pointer;
transition: background 0.15s;
}
.btn-save:hover {
background: var(--pine-deep);
}
.btn-save:disabled {
background: var(--ash-faint);
cursor: default;
}
.btn-secondary {
background: transparent;
color: var(--ash);
border: 1px solid var(--rule);
border-radius: 5px;
padding: 7px 15px;
font-size: 12px;
font-family: 'Inter', sans-serif;
cursor: pointer;
}
.btn-secondary:hover {
background: var(--paper-alt);
}
.editor-area {
flex: 1;
display: flex;
overflow: hidden;
background: var(--paper);
}
.editor-area > section {
flex: 1;
}
/* ---------- Search overlay ---------- */
.search-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--paper);
z-index: 10;
overflow-y: auto;
}
.search-results-header {
padding: 24px 30px 12px;
color: var(--ash);
font-size: 12.5px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: baseline;
gap: 8px;
}
.search-results-header .big-count {
font-family: 'Fraunces', serif;
font-size: 18px;
font-weight: 600;
color: var(--ink);
}
.result-item {
padding: 16px 30px;
border-bottom: 1px solid var(--rule);
cursor: pointer;
transition: background 0.1s;
display: flex;
gap: 12px;
}
.result-item:hover {
background: var(--paper-alt);
}
.result-body {
flex: 1;
min-width: 0;
}
.result-path {
font-size: 12px;
color: var(--ash);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.result-title {
font-family: 'Fraunces', serif;
font-weight: 600;
color: var(--ink);
}
.result-snippet {
font-size: 12.5px;
color: var(--ash);
line-height: 1.65;
}
.result-snippet em {
background: var(--amber-soft);
color: #8a5a12;
padding: 0 2px;
border-radius: 2px;
font-weight: 600;
font-style: normal;
}
.no-results {
padding: 60px 30px;
text-align: center;
color: var(--ash-faint);
font-size: 13px;
}
.no-results .serif {
display: block;
font-size: 16px;
color: var(--ash);
margin-bottom: 6px;
}
/* ---------- Status bar ---------- */
.statusbar {
height: 28px;
background: var(--paper-alt);
border-top: 1px solid var(--rule);
color: var(--ash);
font-family: 'IBM Plex Mono', monospace;
font-size: 10.5px;
display: flex;
align-items: center;
padding: 0 18px;
gap: 18px;
flex-shrink: 0;
}
/* ---------- Upload modal ---------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(27, 27, 24, 0.32);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--paper);
border-radius: 10px;
width: 460px;
box-shadow: var(--shadow);
overflow: hidden;
}
.modal-header {
padding: 18px 22px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h3 {
margin: 0;
font-family: 'Fraunces', serif;
font-size: 16px;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
color: var(--ash);
cursor: pointer;
padding: 4px;
display: flex;
}
.modal-body {
padding: 22px;
}
.dropzone {
border: 1.5px dashed var(--rule);
border-radius: var(--radius);
padding: 36px 20px;
text-align: center;
color: var(--ash);
font-size: 12.5px;
transition: all 0.15s;
cursor: pointer;
}
.dropzone.dragover {
border-color: var(--pine);
background: var(--pine-soft);
color: var(--pine-deep);
}
.dropzone svg {
display: block;
margin: 0 auto 12px;
color: var(--ash-faint);
}
.dropzone.dragover svg {
color: var(--pine);
}
.dropzone .hint {
font-family: 'IBM Plex Mono', monospace;
font-size: 10.5px;
color: var(--ash-faint);
margin-top: 4px;
}
.upload-error {
margin-top: 12px;
padding: 10px 12px;
background: #fdece6;
color: #a9401f;
border-radius: 5px;
font-size: 12px;
}
.modal-footer {
padding: 14px 22px;
border-top: 1px solid var(--rule);
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@ -1,9 +1,9 @@
import { render, screen } from '@testing-library/react';
import { test, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
import App from '../src/App';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';
import { listDocuments, getDocument, search } from '../src/api/client';
import { listDocuments, getDocument, search, uploadDocument } from '../src/api/client';
vi.mock('../src/api/client');
@ -21,6 +21,7 @@ beforeEach(() => {
vi.mocked(listDocuments).mockReset();
vi.mocked(getDocument).mockReset();
vi.mocked(search).mockReset();
vi.mocked(uploadDocument).mockReset();
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
vi.mocked(getDocument).mockResolvedValue({
key: '',
@ -30,6 +31,10 @@ beforeEach(() => {
vi.mocked(search).mockResolvedValue({ results: [] });
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders the DocHub heading', () => {
render(<App />);
expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument();
@ -100,3 +105,127 @@ test('searching shows the results overlay, and clicking a result opens the docum
const textarea = await screen.findByTestId('monaco-editor-mock');
expect(textarea).toHaveValue('# 架构设计');
});
test('jumping to a search result clears the search input so it does not show stale text', async () => {
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
vi.mocked(search).mockResolvedValue({
results: [{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' }],
});
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
});
const user = userEvent.setup();
render(<App />);
const searchInput = screen.getByRole('textbox', { name: '搜索文档' }) as HTMLInputElement;
await user.type(searchInput, '检索');
await waitFor(
() => {
expect(screen.getByTestId('search-result-产品文档/架构设计.md')).toBeInTheDocument();
},
{ timeout: 1000 }
);
await user.click(screen.getByTestId('search-result-产品文档/架构设计.md'));
await waitFor(() => {
expect(searchInput).toHaveValue('');
});
});
test('typing a query does not show a false "no results" message before the debounced search resolves', async () => {
vi.useFakeTimers();
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
vi.mocked(search).mockResolvedValue({ results: [] });
render(<App />);
const searchInput = screen.getByRole('textbox', { name: '搜索文档' });
fireEvent.change(searchInput, { target: { value: '检索' } });
expect(screen.queryByTestId('search-no-results')).not.toBeInTheDocument();
await vi.advanceTimersByTimeAsync(300);
expect(screen.getByTestId('search-no-results')).toBeInTheDocument();
vi.useRealTimers();
});
test('switching files while the current document is dirty prompts for confirmation', async () => {
vi.mocked(listDocuments).mockResolvedValue({
folders: [],
files: [
{ key: '文档A.md', name: '文档A.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
{ key: '文档B.md', name: '文档B.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
],
});
vi.mocked(getDocument).mockImplementation((key: string) =>
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown' })
);
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<App />);
await user.click(await screen.findByText('文档A.md'));
const textarea = await screen.findByTestId('monaco-editor-mock');
await user.type(textarea, '修改内容');
await user.click(screen.getByText('文档B.md'));
expect(confirmSpy).toHaveBeenCalled();
expect(await screen.findByTestId('monaco-editor-mock')).toHaveValue('内容-文档A.md修改内容');
});
test('confirming the discard prompt proceeds to switch files', async () => {
vi.mocked(listDocuments).mockResolvedValue({
folders: [],
files: [
{ key: '文档A.md', name: '文档A.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
{ key: '文档B.md', name: '文档B.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
],
});
vi.mocked(getDocument).mockImplementation((key: string) =>
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown' })
);
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<App />);
await user.click(await screen.findByText('文档A.md'));
const textarea = await screen.findByTestId('monaco-editor-mock');
await user.type(textarea, '修改内容');
await user.click(screen.getByText('文档B.md'));
await waitFor(async () => {
expect(await screen.findByTestId('monaco-editor-mock')).toHaveValue('内容-文档B.md');
});
});
test('a successful upload refreshes the file tree', async () => {
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
vi.mocked(uploadDocument).mockResolvedValue({ key: '新文档.md', name: '新文档.md', size: 5 });
const user = userEvent.setup();
render(<App />);
await waitFor(() => {
expect(listDocuments).toHaveBeenCalledTimes(1);
});
await user.click(screen.getByRole('button', { name: '上传文档' }));
const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' });
const input = screen.getByTestId('upload-file-input');
await user.upload(input, file);
await waitFor(() => {
expect(listDocuments).toHaveBeenCalledTimes(2);
});
});

View File

@ -1,4 +1,4 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { test, expect, vi, beforeEach } from 'vitest';
import { listDocuments, getDocument, saveDocument, deleteDocument, uploadDocument, search } from '../../src/api/client';
beforeEach(() => {
@ -10,33 +10,33 @@ test('listDocuments calls GET /api/documents with the path query param and retur
folders: ['产品文档'],
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 512, modifiedAt: '2026-08-01T10:00:00Z' }],
};
global.fetch = vi.fn().mockResolvedValue({
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch;
const result = await listDocuments('产品文档');
expect(global.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`);
expect(globalThis.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`);
expect(result).toEqual(mockResponse);
});
test('listDocuments throws when the response is not ok', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch;
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch;
await expect(listDocuments('')).rejects.toThrow('Failed to list documents: 500');
});
test('getDocument calls GET /api/documents/{key} preserving slashes in the key', async () => {
const mockResponse = { key: '产品文档/架构设计.md', content: '# 架构设计', contentType: 'text/markdown' };
global.fetch = vi.fn().mockResolvedValue({
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch;
const result = await getDocument('产品文档/架构设计.md');
expect(global.fetch).toHaveBeenCalledWith(
expect(globalThis.fetch).toHaveBeenCalledWith(
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`
);
expect(result).toEqual(mockResponse);
@ -44,14 +44,14 @@ test('getDocument calls GET /api/documents/{key} preserving slashes in the key',
test('saveDocument calls PUT /api/documents/{key} with content and contentType', async () => {
const mockResponse = { key: '产品文档/架构设计.md', updatedAt: '2026-08-01T10:05:00Z' };
global.fetch = vi.fn().mockResolvedValue({
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch;
const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown');
expect(global.fetch).toHaveBeenCalledWith(
expect(globalThis.fetch).toHaveBeenCalledWith(
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
{
method: 'PUT',
@ -63,11 +63,11 @@ test('saveDocument calls PUT /api/documents/{key} with content and contentType',
});
test('deleteDocument calls DELETE /api/documents/{key} and resolves with no value', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch;
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch;
await expect(deleteDocument('产品文档/架构设计.md')).resolves.toBeUndefined();
expect(global.fetch).toHaveBeenCalledWith(
expect(globalThis.fetch).toHaveBeenCalledWith(
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
{ method: 'DELETE' }
);
@ -75,7 +75,7 @@ test('deleteDocument calls DELETE /api/documents/{key} and resolves with no valu
test('uploadDocument posts a multipart form with file and path fields', async () => {
const mockResponse = { key: '产品文档/新文档.md', name: '新文档.md', size: 42 };
global.fetch = vi.fn().mockResolvedValue({
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch;
@ -83,29 +83,45 @@ test('uploadDocument posts a multipart form with file and path fields', async ()
const result = await uploadDocument(file, '产品文档');
expect(global.fetch).toHaveBeenCalledTimes(1);
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('/api/upload');
expect(init.method).toBe('POST');
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: [
{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' },
],
};
global.fetch = vi.fn().mockResolvedValue({
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch;
const result = await search('检索');
expect(global.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`);
expect(globalThis.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`);
expect(result).toEqual(mockResponse);
});

View File

@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { vi, test, expect, beforeEach } from 'vitest';
import DocumentEditor from '../../src/components/DocumentEditor';
import { getDocument, saveDocument } from '../../src/api/client';
import { deleteDocument, getDocument, saveDocument } from '../../src/api/client';
vi.mock('../../src/api/client');
@ -18,6 +18,7 @@ vi.mock('@monaco-editor/react', () => ({
beforeEach(() => {
vi.mocked(getDocument).mockReset();
vi.mocked(saveDocument).mockReset();
vi.mocked(deleteDocument).mockReset();
});
test('shows a loading state, then loads and displays document content', async () => {
@ -27,7 +28,7 @@ test('shows a loading state, then loads and displays document content', async ()
contentType: 'text/markdown',
});
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
@ -52,7 +53,7 @@ test('typing marks the document dirty and saving clears the dirty state', async
});
const user = userEvent.setup();
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
const textarea = await screen.findByTestId('monaco-editor-mock');
await user.clear(textarea);
@ -70,3 +71,133 @@ test('typing marks the document dirty and saving clears the dirty state', async
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
});
});
test('save failure keeps the editor and unsaved content visible, shows an error, and allows retry', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
});
vi.mocked(saveDocument).mockRejectedValueOnce(new Error('network error'));
vi.mocked(saveDocument).mockResolvedValueOnce({
key: '产品文档/架构设计.md',
updatedAt: '2026-08-01T10:05:00Z',
});
const user = userEvent.setup();
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
const textarea = await screen.findByTestId('monaco-editor-mock');
await user.clear(textarea);
await user.type(textarea, '# 未保存的修改');
const saveButton = screen.getByRole('button', { name: '保存' });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByTestId('save-error')).toBeInTheDocument();
});
expect(screen.getByTestId('document-editor')).toBeInTheDocument();
expect(screen.getByTestId('monaco-editor-mock')).toHaveValue('# 未保存的修改');
expect(screen.getByTestId('save-status')).toHaveTextContent('未保存');
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
});
expect(screen.queryByTestId('save-error')).not.toBeInTheDocument();
});
test('calls onDirtyChange when dirty state changes', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
});
const onDirtyChange = vi.fn();
const user = userEvent.setup();
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} onDeleted={vi.fn()} />);
const textarea = await screen.findByTestId('monaco-editor-mock');
await waitFor(() => {
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
});
await user.type(textarea, '追加内容');
await waitFor(() => {
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
});
});
test('clicking delete confirms, calls deleteDocument, and calls onDeleted on success', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
});
vi.mocked(deleteDocument).mockResolvedValue(undefined);
const onDeleted = vi.fn();
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
await screen.findByTestId('monaco-editor-mock');
const deleteButton = screen.getByRole('button', { name: '删除' });
await user.click(deleteButton);
expect(confirmSpy).toHaveBeenCalledWith('确定要删除这篇文档吗?此操作无法撤销。');
await waitFor(() => {
expect(deleteDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
});
await waitFor(() => {
expect(onDeleted).toHaveBeenCalled();
});
});
test('clicking delete does nothing if the confirmation is declined', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
});
const onDeleted = vi.fn();
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
await screen.findByTestId('monaco-editor-mock');
const deleteButton = screen.getByRole('button', { name: '删除' });
await user.click(deleteButton);
expect(deleteDocument).not.toHaveBeenCalled();
expect(onDeleted).not.toHaveBeenCalled();
});
test('shows an inline error and does not call onDeleted when delete fails', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
});
vi.mocked(deleteDocument).mockRejectedValue(new Error('network error'));
const onDeleted = vi.fn();
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
await screen.findByTestId('monaco-editor-mock');
const deleteButton = screen.getByRole('button', { name: '删除' });
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByTestId('delete-error')).toBeInTheDocument();
});
expect(onDeleted).not.toHaveBeenCalled();
});

View File

@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi, test, expect, beforeEach } from 'vitest';
import FileExplorer from '../../src/components/FileExplorer';
import { listDocuments } from '../../src/api/client';
@ -67,3 +67,19 @@ test('shows an error message when listDocuments rejects', async () => {
expect(await screen.findByTestId('file-explorer-error')).toBeInTheDocument();
});
test('changing refreshKey re-fetches the root listing', async () => {
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
const { rerender } = render(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} refreshKey={0} />);
await waitFor(() => {
expect(listDocuments).toHaveBeenCalledTimes(1);
});
rerender(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} refreshKey={1} />);
await waitFor(() => {
expect(listDocuments).toHaveBeenCalledTimes(2);
});
});

View File

@ -1,7 +1,19 @@
import { useState } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
import SearchBar from '../../src/components/SearchBar';
import { search } from '../../src/api/client';
import { search, type SearchResult } from '../../src/api/client';
function ControlledSearchBar({
onResults,
debounceMs,
}: {
onResults: (results: SearchResult[]) => void;
debounceMs?: number;
}) {
const [value, setValue] = useState('');
return <SearchBar value={value} onQueryChange={setValue} onResults={onResults} debounceMs={debounceMs} />;
}
vi.mock('../../src/api/client');
@ -19,7 +31,7 @@ test('debounces input for 300ms before calling search', async () => {
const onQueryChange = vi.fn();
const onResults = vi.fn();
render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);
render(<SearchBar value="" onQueryChange={onQueryChange} onResults={onResults} />);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: '检索' } });
@ -34,10 +46,9 @@ test('debounces input for 300ms before calling search', async () => {
test('clearing the input immediately reports empty results without calling search', async () => {
vi.mocked(search).mockResolvedValue({ results: [] });
const onQueryChange = vi.fn();
const onResults = vi.fn();
render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);
render(<ControlledSearchBar onResults={onResults} />);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'x' } });
@ -46,3 +57,17 @@ test('clearing the input immediately reports empty results without calling searc
expect(onResults).toHaveBeenCalledWith([]);
});
test('reflects the value prop, allowing a parent to control/reset the input', () => {
const onQueryChange = vi.fn();
const onResults = vi.fn();
const { rerender } = render(<SearchBar value="检索" onQueryChange={onQueryChange} onResults={onResults} />);
const input = screen.getByRole('textbox') as HTMLInputElement;
expect(input).toHaveValue('检索');
rerender(<SearchBar value="" onQueryChange={onQueryChange} onResults={onResults} />);
expect(input).toHaveValue('');
});

View File

@ -39,6 +39,44 @@ test('selecting a file through the hidden input uploads it', async () => {
});
});
test('a failed upload shows an error message and keeps the modal open for retry', async () => {
vi.mocked(uploadDocument).mockRejectedValue(new Error('Failed to upload document: 400'));
const onUploaded = vi.fn();
const user = userEvent.setup();
render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
await user.click(screen.getByRole('button', { name: '上传文档' }));
const file = new File(['data'], '恶意程序.exe', { type: 'application/octet-stream' });
const input = screen.getByTestId('upload-file-input');
await user.upload(input, file);
await waitFor(() => {
expect(screen.getByTestId('upload-error')).toHaveTextContent('Failed to upload document: 400');
});
expect(screen.getByTestId('upload-modal')).toBeInTheDocument();
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();
@ -59,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();
});
});