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: class Settings:
def __init__(self): def __init__(self):
self.minio_endpoint = os.environ.get("MINIO_ENDPOINT", "localhost:9000") self.storage_dir = os.environ.get("STORAGE_DIR", "/data/documents")
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.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700") self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700")
self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "") self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "")
self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents") 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() settings = Settings()

View File

@ -1,29 +1,30 @@
from functools import lru_cache from functools import lru_cache
import meilisearch import meilisearch
from minio import Minio
from app.config import settings from app.config import settings
from app.document_service import DocumentService 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 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 @lru_cache
def get_document_service() -> DocumentService: def get_document_service() -> DocumentService:
minio_sdk = Minio( file_client = _get_file_client()
settings.minio_endpoint, search_client = SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index)
access_key=settings.minio_access_key, return DocumentService(file_client, search_client)
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)
@lru_cache @lru_cache
def get_search_client() -> SearchClient: def get_search_client() -> SearchClient:
meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key) return SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index)
return SearchClient(meili_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") 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: def _extension_of(key: str) -> str:
name = key.rsplit("/", 1)[-1] name = key.rsplit("/", 1)[-1]
if "." not in name: if "." not in name:
@ -42,13 +53,13 @@ def _guess_content_type(key: str) -> str:
class DocumentService: class DocumentService:
def __init__(self, minio_client, search_client): def __init__(self, file_client, search_client):
self._minio_client = minio_client self._file_client = file_client
self._search_client = search_client self._search_client = search_client
def list_documents(self, path: str) -> dict: def list_documents(self, path: str) -> dict:
prefix = path if not path or path.endswith("/") else f"{path}/" 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 = [] folders = []
files = [] files = []
for entry in entries: for entry in entries:
@ -68,8 +79,9 @@ class DocumentService:
return {"folders": folders, "files": files} return {"folders": folders, "files": files}
def get_document(self, key: str) -> dict: def get_document(self, key: str) -> dict:
_validate_path(key)
try: try:
data = self._minio_client.get_object(key) data = self._file_client.get_object(key)
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise DocumentNotFoundError(key) from exc raise DocumentNotFoundError(key) from exc
try: try:
@ -94,7 +106,8 @@ class DocumentService:
return text return text
def save_document(self, key: str, content: bytes, content_type: str) -> dict: 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: try:
text = self._extract_text(content, content_type) text = self._extract_text(content, content_type)
title = key.rsplit("/", 1)[-1] title = key.rsplit("/", 1)[-1]
@ -105,13 +118,15 @@ class DocumentService:
return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()} return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()}
def delete_document(self, key: str) -> None: def delete_document(self, key: str) -> None:
self._minio_client.delete_object(key) _validate_path(key)
self._file_client.delete_object(key)
try: try:
self._search_client.delete_document(key) self._search_client.delete_document(key)
except Exception: except Exception:
logger.exception("Failed to delete search index entry for %s", key) logger.exception("Failed to delete search index entry for %s", key)
def upload_document(self, key: str, data: bytes, content_type: str) -> dict: def upload_document(self, key: str, data: bytes, content_type: str) -> dict:
_validate_path(key)
extension = _extension_of(key) extension = _extension_of(key)
if extension not in ALLOWED_UPLOAD_EXTENSIONS: if extension not in ALLOWED_UPLOAD_EXTENSIONS:
raise UnsupportedFileTypeError(extension) 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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routers import documents, search from app.routers import documents, search
logger = logging.getLogger(__name__)
app = FastAPI(title="DocHub API") app = FastAPI(title="DocHub API")
app.add_middleware( 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, DocumentNotFoundError,
DocumentService, DocumentService,
FileTooLargeError, FileTooLargeError,
InvalidPathError,
UnsupportedFileTypeError, UnsupportedFileTypeError,
) )
@ -28,18 +29,26 @@ def get_document(key: str, service: DocumentService = Depends(get_document_servi
return service.get_document(key) return service.get_document(key)
except DocumentNotFoundError: except DocumentNotFoundError:
raise HTTPException(status_code=404, detail="Document not found") 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}") @router.put("/api/documents/{key:path}")
def save_document( def save_document(
key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service) key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service)
): ):
try:
return service.save_document(key, body.content.encode("utf-8"), body.contentType) 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) @router.delete("/api/documents/{key:path}", status_code=204)
def delete_document(key: str, service: DocumentService = Depends(get_document_service)): def delete_document(key: str, service: DocumentService = Depends(get_document_service)):
try:
service.delete_document(key) service.delete_document(key)
except InvalidPathError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return None return None
@ -47,12 +56,14 @@ def delete_document(key: str, service: DocumentService = Depends(get_document_se
async def upload_document( async def upload_document(
file: UploadFile = File(...), file: UploadFile = File(...),
path: str = Form(...), path: str = Form(...),
relative_path: str | None = Form(None),
service: DocumentService = Depends(get_document_service), service: DocumentService = Depends(get_document_service),
): ):
data = await file.read() 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" content_type = file.content_type or "application/octet-stream"
try: try:
return service.upload_document(key, data, content_type) 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)) raise HTTPException(status_code=400, detail=str(exc))

View File

@ -1,6 +1,5 @@
fastapi==0.115.0 fastapi==0.115.0
uvicorn[standard]==0.30.6 uvicorn[standard]==0.30.6
minio==7.2.9
meilisearch==0.31.5 meilisearch==0.31.5
pytest==8.3.3 pytest==8.3.3
httpx==0.27.2 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, DocumentService,
DocumentNotFoundError, DocumentNotFoundError,
FileTooLargeError, FileTooLargeError,
InvalidPathError,
MAX_UPLOAD_SIZE_BYTES, MAX_UPLOAD_SIZE_BYTES,
UnsupportedFileTypeError, UnsupportedFileTypeError,
) )
class FakeMinioClient: class FakeFileClient:
def __init__(self): def __init__(self):
self.entries = [] self.entries = []
self.stored = {} self.stored = {}
@ -54,13 +55,13 @@ class FakeSearchClient:
def test_list_documents_at_root_returns_folders_and_files(): def test_list_documents_at_root_returns_folders_and_files():
minio_client = FakeMinioClient() file_client = FakeFileClient()
minio_client.entries = [ file_client.entries = [
{"key": "产品文档/", "is_dir": True, "size": 0, "modifiedAt": None}, {"key": "产品文档/", "is_dir": True, "size": 0, "modifiedAt": None},
{"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"}, {"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("") 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(): def test_get_document_returns_content_and_content_type():
minio_client = FakeMinioClient() file_client = FakeFileClient()
minio_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 MinIO 存储文档。".encode("utf-8") file_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 Garage 存储文档。".encode("utf-8")
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(file_client, FakeSearchClient())
result = service.get_document("产品文档/架构设计.md") 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(): def test_get_document_raises_not_found_for_missing_key():
service = DocumentService(FakeMinioClient(), FakeSearchClient()) service = DocumentService(FakeFileClient(), FakeSearchClient())
with pytest.raises(DocumentNotFoundError): with pytest.raises(DocumentNotFoundError):
service.get_document("不存在/文档.md") service.get_document("不存在/文档.md")
def test_get_document_returns_base64_content_for_binary_data_without_raising(): 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" binary_data = b"\x89PNG\r\n\x1a\n\x00\x01\xff\xfe"
minio_client.stored["产品文档/图片.png"] = binary_data file_client.stored["产品文档/图片.png"] = binary_data
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(file_client, FakeSearchClient())
result = service.get_document("产品文档/图片.png") 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(): def test_get_document_returns_utf8_encoding_for_text_content():
minio_client = FakeMinioClient() file_client = FakeFileClient()
minio_client.stored["产品文档/架构设计.md"] = "# 架构设计".encode("utf-8") file_client.stored["产品文档/架构设计.md"] = "# 架构设计".encode("utf-8")
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(file_client, FakeSearchClient())
result = service.get_document("产品文档/架构设计.md") result = service.get_document("产品文档/架构设计.md")
assert result["encoding"] == "utf-8" assert result["encoding"] == "utf-8"
def test_save_document_writes_to_minio_then_indexes_content(): def test_save_document_writes_to_garage_then_indexes_content():
minio_client = FakeMinioClient() file_client = FakeFileClient()
search_client = FakeSearchClient() search_client = FakeSearchClient()
service = DocumentService(minio_client, search_client) service = DocumentService(file_client, search_client)
content = "# 架构设计\n\nDocHub 使用 **MinIO** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8") content = "# 架构设计\n\nDocHub 使用 **Garage** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8")
result = service.save_document("产品文档/架构设计.md", content, "text/markdown") 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]["key"] == "产品文档/架构设计.md"
assert search_client.indexed[0]["path"] == "产品文档" assert search_client.indexed[0]["path"] == "产品文档"
assert search_client.indexed[0]["title"] == "架构设计.md" 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 "#" not in search_client.indexed[0]["content"]
assert "key" in result and "updatedAt" in result assert "key" in result and "updatedAt" in result
def test_save_document_does_not_fail_when_index_update_fails(): def test_save_document_does_not_fail_when_index_update_fails():
minio_client = FakeMinioClient() file_client = FakeFileClient()
search_client = FakeSearchClient(fail_on_index=True) search_client = FakeSearchClient(fail_on_index=True)
service = DocumentService(minio_client, search_client) service = DocumentService(file_client, search_client)
content = b"# doc" content = b"# doc"
result = service.save_document("产品文档/架构设计.md", content, "text/markdown") result = service.save_document("产品文档/架构设计.md", content, "text/markdown")
assert minio_client.stored["产品文档/架构设计.md"] == content assert file_client.stored["产品文档/架构设计.md"] == content
assert result["key"] == "产品文档/架构设计.md" assert result["key"] == "产品文档/架构设计.md"
def test_delete_document_removes_from_minio_and_search_index(): def test_delete_document_removes_from_garage_and_search_index():
minio_client = FakeMinioClient() file_client = FakeFileClient()
minio_client.stored["产品文档/旧文档.md"] = b"old content" file_client.stored["产品文档/旧文档.md"] = b"old content"
search_client = FakeSearchClient() search_client = FakeSearchClient()
service = DocumentService(minio_client, search_client) service = DocumentService(file_client, search_client)
service.delete_document("产品文档/旧文档.md") service.delete_document("产品文档/旧文档.md")
assert "产品文档/旧文档.md" not in minio_client.stored assert "产品文档/旧文档.md" not in file_client.stored
assert search_client.deleted == ["产品文档/旧文档.md"] assert search_client.deleted == ["产品文档/旧文档.md"]
def test_delete_document_does_not_fail_when_index_delete_fails(): def test_delete_document_does_not_fail_when_index_delete_fails():
minio_client = FakeMinioClient() file_client = FakeFileClient()
minio_client.stored["产品文档/旧文档.md"] = b"old content" file_client.stored["产品文档/旧文档.md"] = b"old content"
search_client = FakeSearchClient(fail_on_delete=True) search_client = FakeSearchClient(fail_on_delete=True)
service = DocumentService(minio_client, search_client) service = DocumentService(file_client, search_client)
service.delete_document("产品文档/旧文档.md") 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(): def test_upload_document_writes_file_and_returns_metadata():
minio_client = FakeMinioClient() file_client = FakeFileClient()
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(file_client, FakeSearchClient())
data = b"# uploaded doc" data = b"# uploaded doc"
result = service.upload_document("产品文档/新上传.md", data, "text/markdown") result = service.upload_document("产品文档/新上传.md", data, "text/markdown")
assert result == {"key": "产品文档/新上传.md", "name": "新上传.md", "size": len(data)} 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(): def test_upload_document_rejects_unsupported_file_extension():
minio_client = FakeMinioClient() file_client = FakeFileClient()
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(file_client, FakeSearchClient())
with pytest.raises(UnsupportedFileTypeError): with pytest.raises(UnsupportedFileTypeError):
service.upload_document("产品文档/病毒.exe", b"data", "application/octet-stream") 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(): def test_upload_document_rejects_oversized_file():
minio_client = FakeMinioClient() file_client = FakeFileClient()
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(file_client, FakeSearchClient())
data = b"x" * (MAX_UPLOAD_SIZE_BYTES + 1) data = b"x" * (MAX_UPLOAD_SIZE_BYTES + 1)
with pytest.raises(FileTooLargeError): with pytest.raises(FileTooLargeError):
service.upload_document("产品文档/大文件.md", data, "text/markdown") 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(): def test_upload_document_accepts_allowed_image_extension():
minio_client = FakeMinioClient() file_client = FakeFileClient()
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(file_client, FakeSearchClient())
data = b"\x89PNG\r\n\x1a\n" data = b"\x89PNG\r\n\x1a\n"
result = service.upload_document("产品文档/图片.png", data, "image/png") result = service.upload_document("产品文档/图片.png", data, "image/png")
assert result == {"key": "产品文档/图片.png", "name": "图片.png", "size": len(data)} 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.main import app
from app.dependencies import get_document_service 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: class FakeDocumentService:
@ -23,16 +28,24 @@ class FakeDocumentService:
def get_document(self, key): def get_document(self, key):
if key == "不存在/文档.md": if key == "不存在/文档.md":
raise DocumentNotFoundError(key) raise DocumentNotFoundError(key)
if ".." in key.split("/"):
raise InvalidPathError(key)
return {"key": key, "content": "# 架构设计\n\n内容", "contentType": "text/markdown"} return {"key": key, "content": "# 架构设计\n\n内容", "contentType": "text/markdown"}
def save_document(self, key, content, content_type): def save_document(self, key, content, content_type):
if ".." in key.split("/"):
raise InvalidPathError(key)
self.saved.append((key, content, content_type)) self.saved.append((key, content, content_type))
return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"} return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"}
def delete_document(self, key): def delete_document(self, key):
if ".." in key.split("/"):
raise InvalidPathError(key)
self.deleted.append(key) self.deleted.append(key)
def upload_document(self, key, data, content_type): def upload_document(self, key, data, content_type):
if ".." in key.split("/"):
raise InvalidPathError(key)
if key.endswith(".exe"): if key.endswith(".exe"):
raise UnsupportedFileTypeError(".exe") raise UnsupportedFileTypeError(".exe")
if len(data) > 1024: if len(data) > 1024:
@ -101,6 +114,17 @@ def test_upload_document_returns_201_with_metadata(client):
assert body["size"] == len(b"# new content") 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): def test_upload_document_returns_400_for_unsupported_file_type(client):
response = client.post( response = client.post(
"/api/upload", "/api/upload",
@ -119,3 +143,30 @@ def test_upload_document_returns_400_for_oversized_file(client):
) )
assert response.status_code == 400 assert response.status_code == 400
assert "file too large" in response.json()["detail"] 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): def test_search_passes_query_string_to_search_client(client):
client.get("/api/search", params={"q": "MinIO"}) client.get("/api/search", params={"q": "Garage"})
assert client.fake_client.queries == ["MinIO"] 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 = SearchClient(sdk, index_name="documents")
client.index_document( client.index_document(
"产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 MinIO 存储文档" "产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 Garage 存储文档"
) )
index = sdk.indexes["documents"] index = sdk.indexes["documents"]
@ -49,7 +49,7 @@ def test_index_document_adds_document_with_encoded_id():
assert stored["key"] == "产品文档/架构设计.md" assert stored["key"] == "产品文档/架构设计.md"
assert stored["path"] == "产品文档" assert stored["path"] == "产品文档"
assert stored["title"] == "架构设计.md" assert stored["title"] == "架构设计.md"
assert stored["content"] == "DocHub 使用 MinIO 存储文档" assert stored["content"] == "DocHub 使用 Garage 存储文档"
def test_delete_document_removes_document(): def test_delete_document_removes_document():

View File

@ -6,7 +6,7 @@ def test_html_to_text_strips_tags():
<html> <html>
<body> <body>
<h1>架构设计</h1> <h1>架构设计</h1>
<p>DocHub 是一个基于 <strong>MinIO</strong> 的文档管理系统</p> <p>DocHub 是一个基于 <strong>Garage</strong> 的文档管理系统</p>
<ul> <ul>
<li>浏览文档</li> <li>浏览文档</li>
<li>全文检索</li> <li>全文检索</li>
@ -16,7 +16,7 @@ def test_html_to_text_strips_tags():
""" """
result = html_to_text(html) result = html_to_text(html)
assert "架构设计" in result assert "架构设计" in result
assert "DocHub 是一个基于 MinIO 的文档管理系统。" in result assert "DocHub 是一个基于 Garage 的文档管理系统。" in result
assert "浏览文档" in result assert "浏览文档" in result
assert "<h1>" not in result assert "<h1>" not in result
assert "<strong>" 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) result = markdown_to_text(markdown)
assert "#" not in result assert "#" not in result
@ -41,21 +41,21 @@ DocHub 是一个 **基于 MinIO** 的 _文档管理系统_支持全文检索
assert "_" not in result assert "_" not in result
assert "架构设计" in result assert "架构设计" in result
assert "背景" in result assert "背景" in result
assert "基于 MinIO" in result assert "基于 Garage" in result
assert "文档管理系统" in result assert "文档管理系统" in result
def test_markdown_to_text_strips_links_and_list_markers(): def test_markdown_to_text_strips_links_and_list_markers():
markdown = """## 功能列表 markdown = """## 功能列表
- 浏览 [MinIO](https://min.io) 中存储的文档 - 浏览 [Garage](https://garagehq.deuxfleurs.fr) 中存储的文档
- 支持 [Meilisearch](https://www.meilisearch.com) 全文检索 - 支持 [Meilisearch](https://www.meilisearch.com) 全文检索
1. 在线编辑并保存 1. 在线编辑并保存
""" """
result = markdown_to_text(markdown) result = markdown_to_text(markdown)
assert "[" not in result assert "[" not in result
assert "](" not in result assert "](" not in result
assert "浏览 MinIO 中存储的文档" in result assert "浏览 Garage 中存储的文档" in result
assert "支持 Meilisearch 全文检索" in result assert "支持 Meilisearch 全文检索" in result
assert "在线编辑并保存" 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 [selectedKey, setSelectedKey] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]); 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) { function handleJumpToResult(key: string) {
if (!confirmDiscardIfDirty()) {
return;
}
setSelectedKey(key); setSelectedKey(key);
setSearchQuery(''); setSearchQuery('');
setSearchResults([]); setSearchResults([]);
setIsSearching(false);
}
function handleQueryChange(query: string) {
setSearchQuery(query);
setIsSearching(query.trim() !== '');
}
function handleResults(results: SearchResult[]) {
setSearchResults(results);
setIsSearching(false);
} }
function handleUploaded(result: UploadDocumentResponse) { function handleUploaded(result: UploadDocumentResponse) {
setSelectedKey(result.key); setSelectedKey(result.key);
setRefreshKey((prev) => prev + 1);
}
function handleDeleted() {
setSelectedKey(null);
setRefreshKey((prev) => prev + 1);
} }
return ( return (
<div className="app"> <div className="app">
<div className="topbar"> <div className="topbar">
<h1>DocHub</h1> <h1 className="wordmark" aria-label="DocHub">
<SearchBar onQueryChange={setSearchQuery} onResults={setSearchResults} /> DocHub <span className="subtitle"> · </span>
<UploadButton path="" onUploaded={handleUploaded} /> </h1>
<SearchBar value={searchQuery} onQueryChange={handleQueryChange} onResults={handleResults} />
<UploadButton path={uploadPath} onUploaded={handleUploaded} />
</div> </div>
<div className="body"> <div className="body">
<FileExplorer onSelectFile={setSelectedKey} selectedKey={selectedKey} /> <FileExplorer onSelectFile={handleSelectFile} selectedKey={selectedKey} refreshKey={refreshKey} />
<div className="main" data-testid="main-area"> <div className="main" data-testid="main-area">
{selectedKey ? ( {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> </div>
{searchQuery !== '' && ( {searchQuery !== '' && !isSearching && (
<div className="search-overlay">
<SearchResults query={searchQuery} results={searchResults} onJumpToResult={handleJumpToResult} /> <SearchResults query={searchQuery} results={searchResults} onJumpToResult={handleJumpToResult} />
</div>
)} )}
</div> </div>
<div className="statusbar">
<span>DOCHUB</span>
<span>UTF-8</span>
</div>
</div> </div>
); );
} }

View File

@ -77,10 +77,17 @@ export interface UploadDocumentResponse {
size: number; 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(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('path', path); formData.append('path', path);
if (relativePath) {
formData.append('relative_path', relativePath);
}
const response = await fetch(`${API_BASE_URL}/api/upload`, { const response = await fetch(`${API_BASE_URL}/api/upload`, {
method: 'POST', method: 'POST',
body: formData, body: formData,

View File

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

View File

@ -4,9 +4,10 @@ import { listDocuments, type DocumentFile } from '../api/client';
interface FileExplorerProps { interface FileExplorerProps {
onSelectFile: (key: string) => void; onSelectFile: (key: string) => void;
selectedKey?: string | null; 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 [folders, setFolders] = useState<string[]>([]);
const [files, setFiles] = useState<DocumentFile[]>([]); const [files, setFiles] = useState<DocumentFile[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -18,7 +19,7 @@ export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorer
setFiles(data.files); setFiles(data.files);
}) })
.catch(() => setError('无法加载文档目录')); .catch(() => setError('无法加载文档目录'));
}, []); }, [refreshKey]);
if (error) { if (error) {
return <div data-testid="file-explorer-error">{error}</div>; return <div data-testid="file-explorer-error">{error}</div>;
@ -48,10 +49,14 @@ function FileRow({
return ( return (
<div <div
data-testid={`file-${file.key}`} 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)} 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> </div>
); );
} }
@ -85,7 +90,12 @@ function FolderNode({
return ( return (
<div> <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} {name}
</div> </div>
{expanded && ( {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'; import { search, type SearchResult } from '../api/client';
interface SearchBarProps { interface SearchBarProps {
value: string;
onQueryChange: (query: string) => void; onQueryChange: (query: string) => void;
onResults: (results: SearchResult[]) => void; onResults: (results: SearchResult[]) => void;
debounceMs?: number; debounceMs?: number;
} }
export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) { export default function SearchBar({ value, onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) {
const [value, setValue] = useState('');
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
@ -21,7 +21,6 @@ export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }
function handleChange(event: React.ChangeEvent<HTMLInputElement>) { function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const nextValue = event.target.value; const nextValue = event.target.value;
setValue(nextValue);
onQueryChange(nextValue); onQueryChange(nextValue);
if (timerRef.current) { if (timerRef.current) {
@ -41,6 +40,11 @@ export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }
} }
return ( 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 <input
type="text" type="text"
placeholder="检索全部文档内容…" placeholder="检索全部文档内容…"
@ -48,5 +52,6 @@ export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }
onChange={handleChange} onChange={handleChange}
aria-label="搜索文档" aria-label="搜索文档"
/> />
</div>
); );
} }

View File

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

View File

@ -1,5 +1,6 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { uploadDocument, type UploadDocumentResponse } from '../api/client'; import { uploadDocument, type UploadDocumentResponse } from '../api/client';
import { readFileEntries, type FileWithRelativePath } from '../lib/fileSystemEntry';
interface UploadButtonProps { interface UploadButtonProps {
path: string; path: string;
@ -9,7 +10,16 @@ interface UploadButtonProps {
export default function UploadButton({ path, onUploaded }: UploadButtonProps) { export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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() { function openModal() {
setIsOpen(true); setIsOpen(true);
@ -18,14 +28,25 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
function closeModal() { function closeModal() {
setIsOpen(false); setIsOpen(false);
setIsDragOver(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[]) { function uploadFiles(files: FileList | File[]) {
Array.from(files).forEach((file) => { Array.from(files).forEach((file) => uploadOne({ file }));
uploadDocument(file, path).then((result) => {
onUploaded(result);
});
});
} }
function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) { function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) {
@ -35,9 +56,24 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
event.target.value = ''; 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(); event.preventDefault();
setIsDragOver(false); 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) { if (event.dataTransfer.files) {
uploadFiles(event.dataTransfer.files); uploadFiles(event.dataTransfer.files);
} }
@ -54,10 +90,27 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
return ( 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 && ( {isOpen && (
<div className="modal-backdrop show" data-testid="upload-modal"> <div className="modal-backdrop show" data-testid="upload-modal">
<div className="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 <div
className={isDragOver ? 'dropzone dragover' : 'dropzone'} className={isDragOver ? 'dropzone dragover' : 'dropzone'}
data-testid="dropzone" data-testid="dropzone"
@ -66,8 +119,19 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} 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> </div>
{uploadError && (
<div className="upload-error" data-testid="upload-error">
{uploadError}
</div>
)}
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@ -76,7 +140,22 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleFileInputChange} 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>
</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 React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <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 { render, screen, fireEvent } from '@testing-library/react';
import { test, expect, beforeEach, vi } from 'vitest'; import { test, expect, beforeEach, afterEach, vi } from 'vitest';
import App from '../src/App'; import App from '../src/App';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react'; 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'); vi.mock('../src/api/client');
@ -21,6 +21,7 @@ beforeEach(() => {
vi.mocked(listDocuments).mockReset(); vi.mocked(listDocuments).mockReset();
vi.mocked(getDocument).mockReset(); vi.mocked(getDocument).mockReset();
vi.mocked(search).mockReset(); vi.mocked(search).mockReset();
vi.mocked(uploadDocument).mockReset();
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] }); vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
vi.mocked(getDocument).mockResolvedValue({ vi.mocked(getDocument).mockResolvedValue({
key: '', key: '',
@ -30,6 +31,10 @@ beforeEach(() => {
vi.mocked(search).mockResolvedValue({ results: [] }); vi.mocked(search).mockResolvedValue({ results: [] });
}); });
afterEach(() => {
vi.restoreAllMocks();
});
test('renders the DocHub heading', () => { test('renders the DocHub heading', () => {
render(<App />); render(<App />);
expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument(); 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'); const textarea = await screen.findByTestId('monaco-editor-mock');
expect(textarea).toHaveValue('# 架构设计'); 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'; import { listDocuments, getDocument, saveDocument, deleteDocument, uploadDocument, search } from '../../src/api/client';
beforeEach(() => { beforeEach(() => {
@ -10,33 +10,33 @@ test('listDocuments calls GET /api/documents with the path query param and retur
folders: ['产品文档'], folders: ['产品文档'],
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 512, modifiedAt: '2026-08-01T10:00:00Z' }], 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, ok: true,
json: () => Promise.resolve(mockResponse), json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
const result = await listDocuments('产品文档'); const result = await listDocuments('产品文档');
expect(global.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`); expect(globalThis.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`);
expect(result).toEqual(mockResponse); expect(result).toEqual(mockResponse);
}); });
test('listDocuments throws when the response is not ok', async () => { 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'); await expect(listDocuments('')).rejects.toThrow('Failed to list documents: 500');
}); });
test('getDocument calls GET /api/documents/{key} preserving slashes in the key', async () => { test('getDocument calls GET /api/documents/{key} preserving slashes in the key', async () => {
const mockResponse = { key: '产品文档/架构设计.md', content: '# 架构设计', contentType: 'text/markdown' }; const mockResponse = { key: '产品文档/架构设计.md', content: '# 架构设计', contentType: 'text/markdown' };
global.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve(mockResponse), json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
const result = await getDocument('产品文档/架构设计.md'); const result = await getDocument('产品文档/架构设计.md');
expect(global.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}` `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`
); );
expect(result).toEqual(mockResponse); 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 () => { test('saveDocument calls PUT /api/documents/{key} with content and contentType', async () => {
const mockResponse = { key: '产品文档/架构设计.md', updatedAt: '2026-08-01T10:05:00Z' }; const mockResponse = { key: '产品文档/架构设计.md', updatedAt: '2026-08-01T10:05:00Z' };
global.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve(mockResponse), json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown'); const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown');
expect(global.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`, `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
{ {
method: 'PUT', 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 () => { 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(); await expect(deleteDocument('产品文档/架构设计.md')).resolves.toBeUndefined();
expect(global.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`, `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
{ method: 'DELETE' } { 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 () => { test('uploadDocument posts a multipart form with file and path fields', async () => {
const mockResponse = { key: '产品文档/新文档.md', name: '新文档.md', size: 42 }; const mockResponse = { key: '产品文档/新文档.md', name: '新文档.md', size: 42 };
global.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve(mockResponse), json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch; }) 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, '产品文档'); const result = await uploadDocument(file, '产品文档');
expect(global.fetch).toHaveBeenCalledTimes(1); expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0]; const [url, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('/api/upload'); expect(url).toBe('/api/upload');
expect(init.method).toBe('POST'); expect(init.method).toBe('POST');
const body = init.body as FormData; const body = init.body as FormData;
expect(body.get('file')).toBe(file); expect(body.get('file')).toBe(file);
expect(body.get('path')).toBe('产品文档'); expect(body.get('path')).toBe('产品文档');
expect(body.get('relative_path')).toBeNull();
expect(result).toEqual(mockResponse); 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 () => { test('search calls GET /api/search with the q query param and returns parsed JSON', async () => {
const mockResponse = { const mockResponse = {
results: [ results: [
{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' }, { key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' },
], ],
}; };
global.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve(mockResponse), json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
const result = await search('检索'); const result = await search('检索');
expect(global.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`); expect(globalThis.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`);
expect(result).toEqual(mockResponse); expect(result).toEqual(mockResponse);
}); });

View File

@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { vi, test, expect, beforeEach } from 'vitest'; import { vi, test, expect, beforeEach } from 'vitest';
import DocumentEditor from '../../src/components/DocumentEditor'; 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'); vi.mock('../../src/api/client');
@ -18,6 +18,7 @@ vi.mock('@monaco-editor/react', () => ({
beforeEach(() => { beforeEach(() => {
vi.mocked(getDocument).mockReset(); vi.mocked(getDocument).mockReset();
vi.mocked(saveDocument).mockReset(); vi.mocked(saveDocument).mockReset();
vi.mocked(deleteDocument).mockReset();
}); });
test('shows a loading state, then loads and displays document content', async () => { 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', contentType: 'text/markdown',
}); });
render(<DocumentEditor documentKey="产品文档/架构设计.md" />); render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument(); 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(); const user = userEvent.setup();
render(<DocumentEditor documentKey="产品文档/架构设计.md" />); render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
const textarea = await screen.findByTestId('monaco-editor-mock'); const textarea = await screen.findByTestId('monaco-editor-mock');
await user.clear(textarea); 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('已归档'); 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 { vi, test, expect, beforeEach } from 'vitest';
import FileExplorer from '../../src/components/FileExplorer'; import FileExplorer from '../../src/components/FileExplorer';
import { listDocuments } from '../../src/api/client'; 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(); 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 { render, screen, fireEvent } from '@testing-library/react';
import { vi, test, expect, beforeEach, afterEach } from 'vitest'; import { vi, test, expect, beforeEach, afterEach } from 'vitest';
import SearchBar from '../../src/components/SearchBar'; 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'); vi.mock('../../src/api/client');
@ -19,7 +31,7 @@ test('debounces input for 300ms before calling search', async () => {
const onQueryChange = vi.fn(); const onQueryChange = vi.fn();
const onResults = 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; const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: '检索' } }); 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 () => { test('clearing the input immediately reports empty results without calling search', async () => {
vi.mocked(search).mockResolvedValue({ results: [] }); vi.mocked(search).mockResolvedValue({ results: [] });
const onQueryChange = vi.fn();
const onResults = vi.fn(); const onResults = vi.fn();
render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />); render(<ControlledSearchBar onResults={onResults} />);
const input = screen.getByRole('textbox') as HTMLInputElement; const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'x' } }); fireEvent.change(input, { target: { value: 'x' } });
@ -46,3 +57,17 @@ test('clearing the input immediately reports empty results without calling searc
expect(onResults).toHaveBeenCalledWith([]); 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 () => { test('dropping a file onto the dropzone uploads it', async () => {
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 }); vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
const onUploaded = vi.fn(); 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 }); 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();
});
});