Compare commits
No commits in common. "4a6fdaf99452968ad248add3db25723b46341cd0" and "13789cc0b48643157c9ededaaa77150b158c5885" have entirely different histories.
4a6fdaf994
...
13789cc0b4
15
.gitignore
vendored
15
.gitignore
vendored
@ -1,15 +0,0 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Editor
|
||||
.DS_Store
|
||||
|
||||
# Docs
|
||||
docs/
|
||||
@ -3,11 +3,14 @@ import os
|
||||
|
||||
class Settings:
|
||||
def __init__(self):
|
||||
self.storage_dir = os.environ.get("STORAGE_DIR", "/data/documents")
|
||||
self.minio_endpoint = os.environ.get("MINIO_ENDPOINT", "localhost:9000")
|
||||
self.minio_access_key = os.environ.get("MINIO_ACCESS_KEY", "minioadmin")
|
||||
self.minio_secret_key = os.environ.get("MINIO_SECRET_KEY", "minioadmin")
|
||||
self.minio_bucket = os.environ.get("MINIO_BUCKET", "dochub")
|
||||
self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700")
|
||||
self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "")
|
||||
self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents")
|
||||
self.cors_origins = os.environ.get("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||||
self.cors_origins = os.environ.get("CORS_ORIGINS", "*").split(",")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@ -1,30 +1,29 @@
|
||||
from functools import lru_cache
|
||||
|
||||
import meilisearch
|
||||
from minio import Minio
|
||||
|
||||
from app.config import settings
|
||||
from app.document_service import DocumentService
|
||||
from app.local_file_client import LocalFileClient
|
||||
from app.minio_client import MinioClient
|
||||
from app.search_client import SearchClient
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_file_client() -> LocalFileClient:
|
||||
return LocalFileClient(settings.storage_dir)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_meilisearch_sdk() -> meilisearch.Client:
|
||||
return meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_document_service() -> DocumentService:
|
||||
file_client = _get_file_client()
|
||||
search_client = SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index)
|
||||
return DocumentService(file_client, search_client)
|
||||
minio_sdk = Minio(
|
||||
settings.minio_endpoint,
|
||||
access_key=settings.minio_access_key,
|
||||
secret_key=settings.minio_secret_key,
|
||||
secure=False,
|
||||
)
|
||||
meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
|
||||
minio_client = MinioClient(minio_sdk, settings.minio_bucket)
|
||||
search_client = SearchClient(meili_sdk, settings.meilisearch_index)
|
||||
return DocumentService(minio_client, search_client)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_search_client() -> SearchClient:
|
||||
return SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index)
|
||||
meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
|
||||
return SearchClient(meili_sdk, settings.meilisearch_index)
|
||||
|
||||
@ -26,17 +26,6 @@ class FileTooLargeError(Exception):
|
||||
super().__init__(f"file too large: max {max_size_bytes // (1024 * 1024)}MB")
|
||||
|
||||
|
||||
class InvalidPathError(Exception):
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
super().__init__(f"invalid path: {path}")
|
||||
|
||||
|
||||
def _validate_path(path: str) -> None:
|
||||
if path.startswith("/") or any(segment == ".." for segment in path.split("/")):
|
||||
raise InvalidPathError(path)
|
||||
|
||||
|
||||
def _extension_of(key: str) -> str:
|
||||
name = key.rsplit("/", 1)[-1]
|
||||
if "." not in name:
|
||||
@ -53,13 +42,13 @@ def _guess_content_type(key: str) -> str:
|
||||
|
||||
|
||||
class DocumentService:
|
||||
def __init__(self, file_client, search_client):
|
||||
self._file_client = file_client
|
||||
def __init__(self, minio_client, search_client):
|
||||
self._minio_client = minio_client
|
||||
self._search_client = search_client
|
||||
|
||||
def list_documents(self, path: str) -> dict:
|
||||
prefix = path if not path or path.endswith("/") else f"{path}/"
|
||||
entries = self._file_client.list_objects(prefix)
|
||||
entries = self._minio_client.list_objects(prefix)
|
||||
folders = []
|
||||
files = []
|
||||
for entry in entries:
|
||||
@ -79,9 +68,8 @@ class DocumentService:
|
||||
return {"folders": folders, "files": files}
|
||||
|
||||
def get_document(self, key: str) -> dict:
|
||||
_validate_path(key)
|
||||
try:
|
||||
data = self._file_client.get_object(key)
|
||||
data = self._minio_client.get_object(key)
|
||||
except FileNotFoundError as exc:
|
||||
raise DocumentNotFoundError(key) from exc
|
||||
try:
|
||||
@ -106,8 +94,7 @@ class DocumentService:
|
||||
return text
|
||||
|
||||
def save_document(self, key: str, content: bytes, content_type: str) -> dict:
|
||||
_validate_path(key)
|
||||
self._file_client.put_object(key, content, content_type)
|
||||
self._minio_client.put_object(key, content, content_type)
|
||||
try:
|
||||
text = self._extract_text(content, content_type)
|
||||
title = key.rsplit("/", 1)[-1]
|
||||
@ -118,15 +105,13 @@ class DocumentService:
|
||||
return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()}
|
||||
|
||||
def delete_document(self, key: str) -> None:
|
||||
_validate_path(key)
|
||||
self._file_client.delete_object(key)
|
||||
self._minio_client.delete_object(key)
|
||||
try:
|
||||
self._search_client.delete_document(key)
|
||||
except Exception:
|
||||
logger.exception("Failed to delete search index entry for %s", key)
|
||||
|
||||
def upload_document(self, key: str, data: bytes, content_type: str) -> dict:
|
||||
_validate_path(key)
|
||||
extension = _extension_of(key)
|
||||
if extension not in ALLOWED_UPLOAD_EXTENSIONS:
|
||||
raise UnsupportedFileTypeError(extension)
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
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
|
||||
@ -1,13 +1,9 @@
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import documents, search
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="DocHub API")
|
||||
|
||||
app.add_middleware(
|
||||
|
||||
45
backend/app/minio_client.py
Normal file
45
backend/app/minio_client.py
Normal file
@ -0,0 +1,45 @@
|
||||
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)
|
||||
@ -6,7 +6,6 @@ from app.document_service import (
|
||||
DocumentNotFoundError,
|
||||
DocumentService,
|
||||
FileTooLargeError,
|
||||
InvalidPathError,
|
||||
UnsupportedFileTypeError,
|
||||
)
|
||||
|
||||
@ -29,26 +28,18 @@ def get_document(key: str, service: DocumentService = Depends(get_document_servi
|
||||
return service.get_document(key)
|
||||
except DocumentNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
except InvalidPathError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.put("/api/documents/{key:path}")
|
||||
def save_document(
|
||||
key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service)
|
||||
):
|
||||
try:
|
||||
return service.save_document(key, body.content.encode("utf-8"), body.contentType)
|
||||
except InvalidPathError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return service.save_document(key, body.content.encode("utf-8"), body.contentType)
|
||||
|
||||
|
||||
@router.delete("/api/documents/{key:path}", status_code=204)
|
||||
def delete_document(key: str, service: DocumentService = Depends(get_document_service)):
|
||||
try:
|
||||
service.delete_document(key)
|
||||
except InvalidPathError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
service.delete_document(key)
|
||||
return None
|
||||
|
||||
|
||||
@ -56,14 +47,12 @@ def delete_document(key: str, service: DocumentService = Depends(get_document_se
|
||||
async def upload_document(
|
||||
file: UploadFile = File(...),
|
||||
path: str = Form(...),
|
||||
relative_path: str | None = Form(None),
|
||||
service: DocumentService = Depends(get_document_service),
|
||||
):
|
||||
data = await file.read()
|
||||
name = relative_path if relative_path else file.filename
|
||||
key = f"{path.rstrip('/')}/{name}" if path else name
|
||||
key = f"{path.rstrip('/')}/{file.filename}" if path else file.filename
|
||||
content_type = file.content_type or "application/octet-stream"
|
||||
try:
|
||||
return service.upload_document(key, data, content_type)
|
||||
except (UnsupportedFileTypeError, FileTooLargeError, InvalidPathError) as exc:
|
||||
except (UnsupportedFileTypeError, FileTooLargeError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
minio==7.2.9
|
||||
meilisearch==0.31.5
|
||||
pytest==8.3.3
|
||||
httpx==0.27.2
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
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 == ["*"]
|
||||
@ -4,13 +4,12 @@ from app.document_service import (
|
||||
DocumentService,
|
||||
DocumentNotFoundError,
|
||||
FileTooLargeError,
|
||||
InvalidPathError,
|
||||
MAX_UPLOAD_SIZE_BYTES,
|
||||
UnsupportedFileTypeError,
|
||||
)
|
||||
|
||||
|
||||
class FakeFileClient:
|
||||
class FakeMinioClient:
|
||||
def __init__(self):
|
||||
self.entries = []
|
||||
self.stored = {}
|
||||
@ -55,13 +54,13 @@ class FakeSearchClient:
|
||||
|
||||
|
||||
def test_list_documents_at_root_returns_folders_and_files():
|
||||
file_client = FakeFileClient()
|
||||
file_client.entries = [
|
||||
minio_client = FakeMinioClient()
|
||||
minio_client.entries = [
|
||||
{"key": "产品文档/", "is_dir": True, "size": 0, "modifiedAt": None},
|
||||
{"key": "团队规范/", "is_dir": True, "size": 0, "modifiedAt": None},
|
||||
{"key": "README.md", "is_dir": False, "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"},
|
||||
]
|
||||
service = DocumentService(file_client, FakeSearchClient())
|
||||
service = DocumentService(minio_client, FakeSearchClient())
|
||||
|
||||
result = service.list_documents("")
|
||||
|
||||
@ -72,9 +71,9 @@ def test_list_documents_at_root_returns_folders_and_files():
|
||||
|
||||
|
||||
def test_get_document_returns_content_and_content_type():
|
||||
file_client = FakeFileClient()
|
||||
file_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 Garage 存储文档。".encode("utf-8")
|
||||
service = DocumentService(file_client, FakeSearchClient())
|
||||
minio_client = FakeMinioClient()
|
||||
minio_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 MinIO 存储文档。".encode("utf-8")
|
||||
service = DocumentService(minio_client, FakeSearchClient())
|
||||
|
||||
result = service.get_document("产品文档/架构设计.md")
|
||||
|
||||
@ -84,17 +83,17 @@ def test_get_document_returns_content_and_content_type():
|
||||
|
||||
|
||||
def test_get_document_raises_not_found_for_missing_key():
|
||||
service = DocumentService(FakeFileClient(), FakeSearchClient())
|
||||
service = DocumentService(FakeMinioClient(), FakeSearchClient())
|
||||
|
||||
with pytest.raises(DocumentNotFoundError):
|
||||
service.get_document("不存在/文档.md")
|
||||
|
||||
|
||||
def test_get_document_returns_base64_content_for_binary_data_without_raising():
|
||||
file_client = FakeFileClient()
|
||||
minio_client = FakeMinioClient()
|
||||
binary_data = b"\x89PNG\r\n\x1a\n\x00\x01\xff\xfe"
|
||||
file_client.stored["产品文档/图片.png"] = binary_data
|
||||
service = DocumentService(file_client, FakeSearchClient())
|
||||
minio_client.stored["产品文档/图片.png"] = binary_data
|
||||
service = DocumentService(minio_client, FakeSearchClient())
|
||||
|
||||
result = service.get_document("产品文档/图片.png")
|
||||
|
||||
@ -104,139 +103,105 @@ def test_get_document_returns_base64_content_for_binary_data_without_raising():
|
||||
|
||||
|
||||
def test_get_document_returns_utf8_encoding_for_text_content():
|
||||
file_client = FakeFileClient()
|
||||
file_client.stored["产品文档/架构设计.md"] = "# 架构设计".encode("utf-8")
|
||||
service = DocumentService(file_client, FakeSearchClient())
|
||||
minio_client = FakeMinioClient()
|
||||
minio_client.stored["产品文档/架构设计.md"] = "# 架构设计".encode("utf-8")
|
||||
service = DocumentService(minio_client, FakeSearchClient())
|
||||
|
||||
result = service.get_document("产品文档/架构设计.md")
|
||||
|
||||
assert result["encoding"] == "utf-8"
|
||||
|
||||
|
||||
def test_save_document_writes_to_garage_then_indexes_content():
|
||||
file_client = FakeFileClient()
|
||||
def test_save_document_writes_to_minio_then_indexes_content():
|
||||
minio_client = FakeMinioClient()
|
||||
search_client = FakeSearchClient()
|
||||
service = DocumentService(file_client, search_client)
|
||||
content = "# 架构设计\n\nDocHub 使用 **Garage** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8")
|
||||
service = DocumentService(minio_client, search_client)
|
||||
content = "# 架构设计\n\nDocHub 使用 **MinIO** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8")
|
||||
|
||||
result = service.save_document("产品文档/架构设计.md", content, "text/markdown")
|
||||
|
||||
assert file_client.stored["产品文档/架构设计.md"] == content
|
||||
assert minio_client.stored["产品文档/架构设计.md"] == content
|
||||
assert search_client.indexed[0]["key"] == "产品文档/架构设计.md"
|
||||
assert search_client.indexed[0]["path"] == "产品文档"
|
||||
assert search_client.indexed[0]["title"] == "架构设计.md"
|
||||
assert "Garage" in search_client.indexed[0]["content"]
|
||||
assert "MinIO" in search_client.indexed[0]["content"]
|
||||
assert "#" not in search_client.indexed[0]["content"]
|
||||
assert "key" in result and "updatedAt" in result
|
||||
|
||||
|
||||
def test_save_document_does_not_fail_when_index_update_fails():
|
||||
file_client = FakeFileClient()
|
||||
minio_client = FakeMinioClient()
|
||||
search_client = FakeSearchClient(fail_on_index=True)
|
||||
service = DocumentService(file_client, search_client)
|
||||
service = DocumentService(minio_client, search_client)
|
||||
content = b"# doc"
|
||||
|
||||
result = service.save_document("产品文档/架构设计.md", content, "text/markdown")
|
||||
|
||||
assert file_client.stored["产品文档/架构设计.md"] == content
|
||||
assert minio_client.stored["产品文档/架构设计.md"] == content
|
||||
assert result["key"] == "产品文档/架构设计.md"
|
||||
|
||||
|
||||
def test_delete_document_removes_from_garage_and_search_index():
|
||||
file_client = FakeFileClient()
|
||||
file_client.stored["产品文档/旧文档.md"] = b"old content"
|
||||
def test_delete_document_removes_from_minio_and_search_index():
|
||||
minio_client = FakeMinioClient()
|
||||
minio_client.stored["产品文档/旧文档.md"] = b"old content"
|
||||
search_client = FakeSearchClient()
|
||||
service = DocumentService(file_client, search_client)
|
||||
service = DocumentService(minio_client, search_client)
|
||||
|
||||
service.delete_document("产品文档/旧文档.md")
|
||||
|
||||
assert "产品文档/旧文档.md" not in file_client.stored
|
||||
assert "产品文档/旧文档.md" not in minio_client.stored
|
||||
assert search_client.deleted == ["产品文档/旧文档.md"]
|
||||
|
||||
|
||||
def test_delete_document_does_not_fail_when_index_delete_fails():
|
||||
file_client = FakeFileClient()
|
||||
file_client.stored["产品文档/旧文档.md"] = b"old content"
|
||||
minio_client = FakeMinioClient()
|
||||
minio_client.stored["产品文档/旧文档.md"] = b"old content"
|
||||
search_client = FakeSearchClient(fail_on_delete=True)
|
||||
service = DocumentService(file_client, search_client)
|
||||
service = DocumentService(minio_client, search_client)
|
||||
|
||||
service.delete_document("产品文档/旧文档.md")
|
||||
|
||||
assert "产品文档/旧文档.md" not in file_client.stored
|
||||
assert "产品文档/旧文档.md" not in minio_client.stored
|
||||
|
||||
|
||||
def test_upload_document_writes_file_and_returns_metadata():
|
||||
file_client = FakeFileClient()
|
||||
service = DocumentService(file_client, FakeSearchClient())
|
||||
minio_client = FakeMinioClient()
|
||||
service = DocumentService(minio_client, FakeSearchClient())
|
||||
data = b"# uploaded doc"
|
||||
|
||||
result = service.upload_document("产品文档/新上传.md", data, "text/markdown")
|
||||
|
||||
assert result == {"key": "产品文档/新上传.md", "name": "新上传.md", "size": len(data)}
|
||||
assert file_client.stored["产品文档/新上传.md"] == data
|
||||
assert minio_client.stored["产品文档/新上传.md"] == data
|
||||
|
||||
|
||||
def test_upload_document_rejects_unsupported_file_extension():
|
||||
file_client = FakeFileClient()
|
||||
service = DocumentService(file_client, FakeSearchClient())
|
||||
minio_client = FakeMinioClient()
|
||||
service = DocumentService(minio_client, FakeSearchClient())
|
||||
|
||||
with pytest.raises(UnsupportedFileTypeError):
|
||||
service.upload_document("产品文档/病毒.exe", b"data", "application/octet-stream")
|
||||
|
||||
assert "产品文档/病毒.exe" not in file_client.stored
|
||||
assert "产品文档/病毒.exe" not in minio_client.stored
|
||||
|
||||
|
||||
def test_upload_document_rejects_oversized_file():
|
||||
file_client = FakeFileClient()
|
||||
service = DocumentService(file_client, FakeSearchClient())
|
||||
minio_client = FakeMinioClient()
|
||||
service = DocumentService(minio_client, FakeSearchClient())
|
||||
data = b"x" * (MAX_UPLOAD_SIZE_BYTES + 1)
|
||||
|
||||
with pytest.raises(FileTooLargeError):
|
||||
service.upload_document("产品文档/大文件.md", data, "text/markdown")
|
||||
|
||||
assert "产品文档/大文件.md" not in 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
|
||||
assert "产品文档/大文件.md" not in minio_client.stored
|
||||
|
||||
|
||||
def test_upload_document_accepts_allowed_image_extension():
|
||||
file_client = FakeFileClient()
|
||||
service = DocumentService(file_client, FakeSearchClient())
|
||||
minio_client = FakeMinioClient()
|
||||
service = DocumentService(minio_client, FakeSearchClient())
|
||||
data = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
result = service.upload_document("产品文档/图片.png", data, "image/png")
|
||||
|
||||
assert result == {"key": "产品文档/图片.png", "name": "图片.png", "size": len(data)}
|
||||
assert file_client.stored["产品文档/图片.png"] == data
|
||||
assert minio_client.stored["产品文档/图片.png"] == data
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
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")
|
||||
108
backend/tests/test_minio_client.py
Normal file
108
backend/tests/test_minio_client.py
Normal file
@ -0,0 +1,108 @@
|
||||
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"]
|
||||
@ -3,12 +3,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.dependencies import get_document_service
|
||||
from app.document_service import (
|
||||
DocumentNotFoundError,
|
||||
FileTooLargeError,
|
||||
InvalidPathError,
|
||||
UnsupportedFileTypeError,
|
||||
)
|
||||
from app.document_service import DocumentNotFoundError, FileTooLargeError, UnsupportedFileTypeError
|
||||
|
||||
|
||||
class FakeDocumentService:
|
||||
@ -28,24 +23,16 @@ class FakeDocumentService:
|
||||
def get_document(self, key):
|
||||
if key == "不存在/文档.md":
|
||||
raise DocumentNotFoundError(key)
|
||||
if ".." in key.split("/"):
|
||||
raise InvalidPathError(key)
|
||||
return {"key": key, "content": "# 架构设计\n\n内容", "contentType": "text/markdown"}
|
||||
|
||||
def save_document(self, key, content, content_type):
|
||||
if ".." in key.split("/"):
|
||||
raise InvalidPathError(key)
|
||||
self.saved.append((key, content, content_type))
|
||||
return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"}
|
||||
|
||||
def delete_document(self, key):
|
||||
if ".." in key.split("/"):
|
||||
raise InvalidPathError(key)
|
||||
self.deleted.append(key)
|
||||
|
||||
def upload_document(self, key, data, content_type):
|
||||
if ".." in key.split("/"):
|
||||
raise InvalidPathError(key)
|
||||
if key.endswith(".exe"):
|
||||
raise UnsupportedFileTypeError(".exe")
|
||||
if len(data) > 1024:
|
||||
@ -114,17 +101,6 @@ def test_upload_document_returns_201_with_metadata(client):
|
||||
assert body["size"] == len(b"# new content")
|
||||
|
||||
|
||||
def test_upload_document_uses_relative_path_to_preserve_folder_structure(client):
|
||||
response = client.post(
|
||||
"/api/upload",
|
||||
data={"path": "产品文档", "relative_path": "子目录/新文档.md"},
|
||||
files={"file": ("新文档.md", b"# new content", "text/markdown")},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["key"] == "产品文档/子目录/新文档.md"
|
||||
|
||||
|
||||
def test_upload_document_returns_400_for_unsupported_file_type(client):
|
||||
response = client.post(
|
||||
"/api/upload",
|
||||
@ -143,30 +119,3 @@ def test_upload_document_returns_400_for_oversized_file(client):
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "file too large" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_get_document_returns_400_for_path_traversal_key(client):
|
||||
response = client.get("/api/documents/..%2F..%2Fetc%2Fpasswd")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_put_document_returns_400_for_path_traversal_key(client):
|
||||
response = client.put(
|
||||
"/api/documents/..%2F..%2Fetc%2Fpasswd",
|
||||
json={"content": "x", "contentType": "text/plain"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_delete_document_returns_400_for_path_traversal_key(client):
|
||||
response = client.delete("/api/documents/..%2F..%2Fetc%2Fpasswd")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_upload_document_returns_400_for_path_traversal_in_path(client):
|
||||
response = client.post(
|
||||
"/api/upload",
|
||||
data={"path": "../../etc"},
|
||||
files={"file": ("passwd.md", b"data", "text/markdown")},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
@ -48,5 +48,5 @@ def test_search_returns_empty_results_for_no_match(client):
|
||||
|
||||
|
||||
def test_search_passes_query_string_to_search_client(client):
|
||||
client.get("/api/search", params={"q": "Garage"})
|
||||
assert client.fake_client.queries == ["Garage"]
|
||||
client.get("/api/search", params={"q": "MinIO"})
|
||||
assert client.fake_client.queries == ["MinIO"]
|
||||
|
||||
@ -40,7 +40,7 @@ def test_index_document_adds_document_with_encoded_id():
|
||||
client = SearchClient(sdk, index_name="documents")
|
||||
|
||||
client.index_document(
|
||||
"产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 Garage 存储文档"
|
||||
"产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 MinIO 存储文档"
|
||||
)
|
||||
|
||||
index = sdk.indexes["documents"]
|
||||
@ -49,7 +49,7 @@ def test_index_document_adds_document_with_encoded_id():
|
||||
assert stored["key"] == "产品文档/架构设计.md"
|
||||
assert stored["path"] == "产品文档"
|
||||
assert stored["title"] == "架构设计.md"
|
||||
assert stored["content"] == "DocHub 使用 Garage 存储文档"
|
||||
assert stored["content"] == "DocHub 使用 MinIO 存储文档"
|
||||
|
||||
|
||||
def test_delete_document_removes_document():
|
||||
|
||||
@ -6,7 +6,7 @@ def test_html_to_text_strips_tags():
|
||||
<html>
|
||||
<body>
|
||||
<h1>架构设计</h1>
|
||||
<p>DocHub 是一个基于 <strong>Garage</strong> 的文档管理系统。</p>
|
||||
<p>DocHub 是一个基于 <strong>MinIO</strong> 的文档管理系统。</p>
|
||||
<ul>
|
||||
<li>浏览文档</li>
|
||||
<li>全文检索</li>
|
||||
@ -16,7 +16,7 @@ def test_html_to_text_strips_tags():
|
||||
"""
|
||||
result = html_to_text(html)
|
||||
assert "架构设计" in result
|
||||
assert "DocHub 是一个基于 Garage 的文档管理系统。" in result
|
||||
assert "DocHub 是一个基于 MinIO 的文档管理系统。" in result
|
||||
assert "浏览文档" in result
|
||||
assert "<h1>" not in result
|
||||
assert "<strong>" not in result
|
||||
@ -33,7 +33,7 @@ def test_markdown_to_text_strips_headers_and_emphasis():
|
||||
|
||||
## 背景
|
||||
|
||||
DocHub 是一个 **基于 Garage** 的 _文档管理系统_,支持全文检索。
|
||||
DocHub 是一个 **基于 MinIO** 的 _文档管理系统_,支持全文检索。
|
||||
"""
|
||||
result = markdown_to_text(markdown)
|
||||
assert "#" not in result
|
||||
@ -41,21 +41,21 @@ DocHub 是一个 **基于 Garage** 的 _文档管理系统_,支持全文检索
|
||||
assert "_" not in result
|
||||
assert "架构设计" in result
|
||||
assert "背景" in result
|
||||
assert "基于 Garage" in result
|
||||
assert "基于 MinIO" in result
|
||||
assert "文档管理系统" in result
|
||||
|
||||
|
||||
def test_markdown_to_text_strips_links_and_list_markers():
|
||||
markdown = """## 功能列表
|
||||
|
||||
- 浏览 [Garage](https://garagehq.deuxfleurs.fr) 中存储的文档
|
||||
- 浏览 [MinIO](https://min.io) 中存储的文档
|
||||
- 支持 [Meilisearch](https://www.meilisearch.com) 全文检索
|
||||
1. 在线编辑并保存
|
||||
"""
|
||||
result = markdown_to_text(markdown)
|
||||
assert "[" not in result
|
||||
assert "](" not in result
|
||||
assert "浏览 Garage 中存储的文档" in result
|
||||
assert "浏览 MinIO 中存储的文档" in result
|
||||
assert "支持 Meilisearch 全文检索" in result
|
||||
assert "在线编辑并保存" in result
|
||||
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
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:
|
||||
3609
docs/superpowers/plans/2026-08-01-dochub-implementation.md
Normal file
3609
docs/superpowers/plans/2026-08-01-dochub-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
153
docs/superpowers/specs/2026-08-01-dochub-design.md
Normal file
153
docs/superpowers/specs/2026-08-01-dochub-design.md
Normal file
@ -0,0 +1,153 @@
|
||||
# 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
3559
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,85 +10,37 @@ export default function App() {
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [uploadPath] = useState('');
|
||||
|
||||
function confirmDiscardIfDirty(): boolean {
|
||||
if (!isDirty) {
|
||||
return true;
|
||||
}
|
||||
return window.confirm('有未保存的修改,确定要离开吗?');
|
||||
}
|
||||
|
||||
function handleSelectFile(key: string) {
|
||||
if (!confirmDiscardIfDirty()) {
|
||||
return;
|
||||
}
|
||||
setSelectedKey(key);
|
||||
}
|
||||
|
||||
function handleJumpToResult(key: string) {
|
||||
if (!confirmDiscardIfDirty()) {
|
||||
return;
|
||||
}
|
||||
setSelectedKey(key);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
}
|
||||
|
||||
function handleQueryChange(query: string) {
|
||||
setSearchQuery(query);
|
||||
setIsSearching(query.trim() !== '');
|
||||
}
|
||||
|
||||
function handleResults(results: SearchResult[]) {
|
||||
setSearchResults(results);
|
||||
setIsSearching(false);
|
||||
}
|
||||
|
||||
function handleUploaded(result: UploadDocumentResponse) {
|
||||
setSelectedKey(result.key);
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}
|
||||
|
||||
function handleDeleted() {
|
||||
setSelectedKey(null);
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="topbar">
|
||||
<h1 className="wordmark" aria-label="DocHub">
|
||||
DocHub <span className="subtitle">档案 · 检索</span>
|
||||
</h1>
|
||||
<SearchBar value={searchQuery} onQueryChange={handleQueryChange} onResults={handleResults} />
|
||||
<UploadButton path={uploadPath} onUploaded={handleUploaded} />
|
||||
<h1>DocHub</h1>
|
||||
<SearchBar onQueryChange={setSearchQuery} onResults={setSearchResults} />
|
||||
<UploadButton path="" onUploaded={handleUploaded} />
|
||||
</div>
|
||||
<div className="body">
|
||||
<FileExplorer onSelectFile={handleSelectFile} selectedKey={selectedKey} refreshKey={refreshKey} />
|
||||
<FileExplorer onSelectFile={setSelectedKey} selectedKey={selectedKey} />
|
||||
<div className="main" data-testid="main-area">
|
||||
{selectedKey ? (
|
||||
<DocumentEditor documentKey={selectedKey} onDirtyChange={setIsDirty} onDeleted={handleDeleted} />
|
||||
<DocumentEditor documentKey={selectedKey} />
|
||||
) : (
|
||||
<div className="empty-state" data-testid="empty-state">
|
||||
未选择文档
|
||||
</div>
|
||||
<div data-testid="empty-state">未选择文档</div>
|
||||
)}
|
||||
</div>
|
||||
{searchQuery !== '' && !isSearching && (
|
||||
<div className="search-overlay">
|
||||
<SearchResults query={searchQuery} results={searchResults} onJumpToResult={handleJumpToResult} />
|
||||
</div>
|
||||
{searchQuery !== '' && (
|
||||
<SearchResults query={searchQuery} results={searchResults} onJumpToResult={handleJumpToResult} />
|
||||
)}
|
||||
</div>
|
||||
<div className="statusbar">
|
||||
<span>DOCHUB</span>
|
||||
<span>UTF-8</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -77,17 +77,10 @@ export interface UploadDocumentResponse {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function uploadDocument(
|
||||
file: File,
|
||||
path: string,
|
||||
relativePath?: string,
|
||||
): Promise<UploadDocumentResponse> {
|
||||
export async function uploadDocument(file: File, path: string): Promise<UploadDocumentResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('path', path);
|
||||
if (relativePath) {
|
||||
formData.append('relative_path', relativePath);
|
||||
}
|
||||
const response = await fetch(`${API_BASE_URL}/api/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
|
||||
@ -1,28 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { deleteDocument, getDocument, saveDocument } from '../api/client';
|
||||
import { getDocument, saveDocument } from '../api/client';
|
||||
|
||||
interface DocumentEditorProps {
|
||||
documentKey: string;
|
||||
onDirtyChange?: (isDirty: boolean) => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }: DocumentEditorProps) {
|
||||
export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [originalContent, setOriginalContent] = useState('');
|
||||
const [contentType, setContentType] = useState('text/markdown');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [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);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
setSaveError(null);
|
||||
setError(null);
|
||||
getDocument(documentKey)
|
||||
.then((doc) => {
|
||||
setContent(doc.content);
|
||||
@ -31,98 +25,47 @@ export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoadError('无法加载文档');
|
||||
setError('无法加载文档');
|
||||
setLoading(false);
|
||||
});
|
||||
}, [documentKey]);
|
||||
|
||||
const isDirty = content !== originalContent;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
function handleChange(value: string | undefined) {
|
||||
setContent(value ?? '');
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
saveDocument(documentKey, content, contentType)
|
||||
.then(() => {
|
||||
setOriginalContent(content);
|
||||
setSaving(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setSaveError('保存失败,请重试');
|
||||
setError('保存失败');
|
||||
setSaving(false);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!window.confirm('确定要删除这篇文档吗?此操作无法撤销。')) {
|
||||
return;
|
||||
}
|
||||
setDeleting(true);
|
||||
setDeleteError(null);
|
||||
deleteDocument(documentKey)
|
||||
.then(() => {
|
||||
setDeleting(false);
|
||||
onDeleted();
|
||||
})
|
||||
.catch(() => {
|
||||
setDeleteError('删除失败,请重试');
|
||||
setDeleting(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div data-testid="document-editor-loading">加载中…</div>;
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return <div data-testid="document-editor-error">{loadError}</div>;
|
||||
if (error) {
|
||||
return <div data-testid="document-editor-error">{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="document-editor">
|
||||
<div className="doc-header">
|
||||
<div className="doc-path">
|
||||
<span className="current">{documentKey}</span>
|
||||
</div>
|
||||
<div className="doc-actions">
|
||||
<span className={isDirty ? 'save-status dirty' : 'save-status saved'} data-testid="save-status">
|
||||
<span className="dot" />
|
||||
{isDirty ? '未保存' : '已归档'}
|
||||
</span>
|
||||
{saveError && (
|
||||
<span className="save-error" data-testid="save-error">
|
||||
{saveError}
|
||||
<button onClick={() => setSaveError(null)} aria-label="关闭错误提示">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{deleteError && (
|
||||
<span className="save-error" data-testid="delete-error">
|
||||
{deleteError}
|
||||
<button onClick={() => setDeleteError(null)} aria-label="关闭错误提示">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<button className="btn-secondary" onClick={handleDelete} disabled={deleting}>
|
||||
{deleting ? '删除中…' : '删除'}
|
||||
</button>
|
||||
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-area">
|
||||
<Editor value={content} onChange={handleChange} language={contentType === 'text/markdown' ? 'markdown' : 'html'} />
|
||||
<span data-testid="save-status">{isDirty ? '未保存' : '已归档'}</span>
|
||||
<button onClick={handleSave} disabled={!isDirty || saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
<Editor value={content} onChange={handleChange} language={contentType === 'text/markdown' ? 'markdown' : 'html'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,10 +4,9 @@ import { listDocuments, type DocumentFile } from '../api/client';
|
||||
interface FileExplorerProps {
|
||||
onSelectFile: (key: string) => void;
|
||||
selectedKey?: string | null;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
export default function FileExplorer({ onSelectFile, selectedKey, refreshKey }: FileExplorerProps) {
|
||||
export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorerProps) {
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [files, setFiles] = useState<DocumentFile[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -19,7 +18,7 @@ export default function FileExplorer({ onSelectFile, selectedKey, refreshKey }:
|
||||
setFiles(data.files);
|
||||
})
|
||||
.catch(() => setError('无法加载文档目录'));
|
||||
}, [refreshKey]);
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return <div data-testid="file-explorer-error">{error}</div>;
|
||||
@ -49,14 +48,10 @@ function FileRow({
|
||||
return (
|
||||
<div
|
||||
data-testid={`file-${file.key}`}
|
||||
className={selectedKey === file.key ? 'tree-item file-row active' : 'tree-item file-row'}
|
||||
className={selectedKey === file.key ? 'file-row active' : 'file-row'}
|
||||
onClick={() => onSelectFile(file.key)}
|
||||
>
|
||||
<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>
|
||||
{file.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -90,12 +85,7 @@ function FolderNode({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
<div data-testid={`folder-${path}`} className="folder" onClick={toggle}>
|
||||
{name}
|
||||
</div>
|
||||
{expanded && (
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { search, type SearchResult } from '../api/client';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
onResults: (results: SearchResult[]) => void;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export default function SearchBar({ value, onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) {
|
||||
export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) {
|
||||
const [value, setValue] = useState('');
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -21,6 +21,7 @@ export default function SearchBar({ value, onQueryChange, onResults, debounceMs
|
||||
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const nextValue = event.target.value;
|
||||
setValue(nextValue);
|
||||
onQueryChange(nextValue);
|
||||
|
||||
if (timerRef.current) {
|
||||
@ -40,18 +41,12 @@ export default function SearchBar({ value, onQueryChange, onResults, debounceMs
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="search-box">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="检索全部文档内容…"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-label="搜索文档"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="检索全部文档内容…"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-label="搜索文档"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,33 +8,19 @@ interface SearchResultsProps {
|
||||
|
||||
export default function SearchResults({ results, query, onJumpToResult }: SearchResultsProps) {
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="no-results" data-testid="search-no-results">
|
||||
<span className="serif">没有找到匹配的文档</span>
|
||||
换个关键词试试,或检查拼写
|
||||
</div>
|
||||
);
|
||||
return <div data-testid="search-no-results">没有找到匹配的文档</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="search-results">
|
||||
<div className="search-results-header" data-testid="search-results-count">
|
||||
<span className="big-count">{results.length}</span> 条与「{query}」相关的结果
|
||||
<div data-testid="search-results-count">
|
||||
{results.length} 条与「{query}」相关的结果
|
||||
</div>
|
||||
{results.map((result) => (
|
||||
<div
|
||||
key={result.key}
|
||||
className="result-item"
|
||||
data-testid={`search-result-${result.key}`}
|
||||
onClick={() => onJumpToResult(result.key)}
|
||||
>
|
||||
<span className="catalog-tag">{result.path}</span>
|
||||
<div className="result-body">
|
||||
<div className="result-path">
|
||||
<span className="result-title">{result.title}</span>
|
||||
</div>
|
||||
<div className="result-snippet" dangerouslySetInnerHTML={{ __html: result.snippet }} />
|
||||
</div>
|
||||
<div key={result.key} data-testid={`search-result-${result.key}`} onClick={() => onJumpToResult(result.key)}>
|
||||
<div className="result-title">{result.title}</div>
|
||||
<div className="result-path">{result.path}</div>
|
||||
<div className="result-snippet" dangerouslySetInnerHTML={{ __html: result.snippet }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { uploadDocument, type UploadDocumentResponse } from '../api/client';
|
||||
import { readFileEntries, type FileWithRelativePath } from '../lib/fileSystemEntry';
|
||||
|
||||
interface UploadButtonProps {
|
||||
path: string;
|
||||
@ -10,16 +9,7 @@ interface UploadButtonProps {
|
||||
export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
function setFolderInputRef(node: HTMLInputElement | null) {
|
||||
folderInputRef.current = node;
|
||||
if (node) {
|
||||
node.setAttribute('webkitdirectory', '');
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
setIsOpen(true);
|
||||
@ -28,25 +18,14 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
||||
function closeModal() {
|
||||
setIsOpen(false);
|
||||
setIsDragOver(false);
|
||||
setUploadError(null);
|
||||
}
|
||||
|
||||
function uploadOne(entry: FileWithRelativePath) {
|
||||
setUploadError(null);
|
||||
const promise = entry.relativePath
|
||||
? uploadDocument(entry.file, path, entry.relativePath)
|
||||
: uploadDocument(entry.file, path);
|
||||
promise
|
||||
.then((result) => {
|
||||
onUploaded(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
setUploadError(err instanceof Error ? err.message : '上传失败');
|
||||
});
|
||||
}
|
||||
|
||||
function uploadFiles(files: FileList | File[]) {
|
||||
Array.from(files).forEach((file) => uploadOne({ file }));
|
||||
Array.from(files).forEach((file) => {
|
||||
uploadDocument(file, path).then((result) => {
|
||||
onUploaded(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
@ -56,24 +35,9 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
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>) {
|
||||
function handleDrop(event: React.DragEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const items = event.dataTransfer.items;
|
||||
if (items && items.length > 0 && typeof items[0]?.webkitGetAsEntry === 'function') {
|
||||
const entries = await readFileEntries(items);
|
||||
entries.forEach(uploadOne);
|
||||
return;
|
||||
}
|
||||
if (event.dataTransfer.files) {
|
||||
uploadFiles(event.dataTransfer.files);
|
||||
}
|
||||
@ -90,72 +54,29 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<button onClick={openModal}>上传文档</button>
|
||||
{isOpen && (
|
||||
<div className="modal-backdrop show" data-testid="upload-modal">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h3>上传文档</h3>
|
||||
<button className="modal-close" onClick={closeModal} aria-label="关闭">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div
|
||||
className={isDragOver ? 'dropzone dragover' : 'dropzone'}
|
||||
data-testid="dropzone"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<path d="M12 3v12" />
|
||||
<path d="M6 9l6-6 6 6" />
|
||||
<path d="M4 21h16" />
|
||||
</svg>
|
||||
拖拽文件或文件夹到此处,或点击选择
|
||||
<div className="hint">支持 .md .html 及常见图片格式</div>
|
||||
</div>
|
||||
{uploadError && (
|
||||
<div className="upload-error" data-testid="upload-error">
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
data-testid="upload-file-input"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<input
|
||||
ref={setFolderInputRef}
|
||||
type="file"
|
||||
data-testid="upload-folder-input"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFolderInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn-secondary" onClick={() => folderInputRef.current?.click()}>
|
||||
选择文件夹
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={closeModal}>
|
||||
取消
|
||||
</button>
|
||||
<div
|
||||
className={isDragOver ? 'dropzone dragover' : 'dropzone'}
|
||||
data-testid="dropzone"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
拖拽文件到此处,或点击选择
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
data-testid="upload-file-input"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<button onClick={closeModal}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@ -1,618 +0,0 @@
|
||||
: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;
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { test, expect, beforeEach, vi } from 'vitest';
|
||||
import App from '../src/App';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { listDocuments, getDocument, search, uploadDocument } from '../src/api/client';
|
||||
import { listDocuments, getDocument, search } from '../src/api/client';
|
||||
|
||||
vi.mock('../src/api/client');
|
||||
|
||||
@ -21,7 +21,6 @@ beforeEach(() => {
|
||||
vi.mocked(listDocuments).mockReset();
|
||||
vi.mocked(getDocument).mockReset();
|
||||
vi.mocked(search).mockReset();
|
||||
vi.mocked(uploadDocument).mockReset();
|
||||
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||
vi.mocked(getDocument).mockResolvedValue({
|
||||
key: '',
|
||||
@ -31,10 +30,6 @@ beforeEach(() => {
|
||||
vi.mocked(search).mockResolvedValue({ results: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders the DocHub heading', () => {
|
||||
render(<App />);
|
||||
expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument();
|
||||
@ -105,127 +100,3 @@ test('searching shows the results overlay, and clicking a result opens the docum
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
expect(textarea).toHaveValue('# 架构设计');
|
||||
});
|
||||
|
||||
test('jumping to a search result clears the search input so it does not show stale text', async () => {
|
||||
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||
vi.mocked(search).mockResolvedValue({
|
||||
results: [{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' }],
|
||||
});
|
||||
vi.mocked(getDocument).mockResolvedValue({
|
||||
key: '产品文档/架构设计.md',
|
||||
content: '# 架构设计',
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const searchInput = screen.getByRole('textbox', { name: '搜索文档' }) as HTMLInputElement;
|
||||
await user.type(searchInput, '检索');
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByTestId('search-result-产品文档/架构设计.md')).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('search-result-产品文档/架构设计.md'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchInput).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
test('typing a query does not show a false "no results" message before the debounced search resolves', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||
vi.mocked(search).mockResolvedValue({ results: [] });
|
||||
|
||||
render(<App />);
|
||||
|
||||
const searchInput = screen.getByRole('textbox', { name: '搜索文档' });
|
||||
fireEvent.change(searchInput, { target: { value: '检索' } });
|
||||
|
||||
expect(screen.queryByTestId('search-no-results')).not.toBeInTheDocument();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(screen.getByTestId('search-no-results')).toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('switching files while the current document is dirty prompts for confirmation', async () => {
|
||||
vi.mocked(listDocuments).mockResolvedValue({
|
||||
folders: [],
|
||||
files: [
|
||||
{ key: '文档A.md', name: '文档A.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
||||
{ key: '文档B.md', name: '文档B.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
||||
],
|
||||
});
|
||||
vi.mocked(getDocument).mockImplementation((key: string) =>
|
||||
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown' })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await user.click(await screen.findByText('文档A.md'));
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
await user.type(textarea, '修改内容');
|
||||
|
||||
await user.click(screen.getByText('文档B.md'));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(await screen.findByTestId('monaco-editor-mock')).toHaveValue('内容-文档A.md修改内容');
|
||||
});
|
||||
|
||||
test('confirming the discard prompt proceeds to switch files', async () => {
|
||||
vi.mocked(listDocuments).mockResolvedValue({
|
||||
folders: [],
|
||||
files: [
|
||||
{ key: '文档A.md', name: '文档A.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
||||
{ key: '文档B.md', name: '文档B.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
||||
],
|
||||
});
|
||||
vi.mocked(getDocument).mockImplementation((key: string) =>
|
||||
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown' })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await user.click(await screen.findByText('文档A.md'));
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
await user.type(textarea, '修改内容');
|
||||
|
||||
await user.click(screen.getByText('文档B.md'));
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByTestId('monaco-editor-mock')).toHaveValue('内容-文档B.md');
|
||||
});
|
||||
});
|
||||
|
||||
test('a successful upload refreshes the file tree', async () => {
|
||||
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||
vi.mocked(uploadDocument).mockResolvedValue({ key: '新文档.md', name: '新文档.md', size: 5 });
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listDocuments).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '上传文档' }));
|
||||
const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' });
|
||||
const input = screen.getByTestId('upload-file-input');
|
||||
await user.upload(input, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listDocuments).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { test, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import { listDocuments, getDocument, saveDocument, deleteDocument, uploadDocument, search } from '../../src/api/client';
|
||||
|
||||
beforeEach(() => {
|
||||
@ -10,33 +10,33 @@ test('listDocuments calls GET /api/documents with the path query param and retur
|
||||
folders: ['产品文档'],
|
||||
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 512, modifiedAt: '2026-08-01T10:00:00Z' }],
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await listDocuments('产品文档');
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`);
|
||||
expect(global.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test('listDocuments throws when the response is not ok', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch;
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch;
|
||||
|
||||
await expect(listDocuments('')).rejects.toThrow('Failed to list documents: 500');
|
||||
});
|
||||
|
||||
test('getDocument calls GET /api/documents/{key} preserving slashes in the key', async () => {
|
||||
const mockResponse = { key: '产品文档/架构设计.md', content: '# 架构设计', contentType: 'text/markdown' };
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await getDocument('产品文档/架构设计.md');
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
@ -44,14 +44,14 @@ test('getDocument calls GET /api/documents/{key} preserving slashes in the key',
|
||||
|
||||
test('saveDocument calls PUT /api/documents/{key} with content and contentType', async () => {
|
||||
const mockResponse = { key: '产品文档/架构设计.md', updatedAt: '2026-08-01T10:05:00Z' };
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown');
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
@ -63,11 +63,11 @@ test('saveDocument calls PUT /api/documents/{key} with content and contentType',
|
||||
});
|
||||
|
||||
test('deleteDocument calls DELETE /api/documents/{key} and resolves with no value', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch;
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch;
|
||||
|
||||
await expect(deleteDocument('产品文档/架构设计.md')).resolves.toBeUndefined();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
@ -75,7 +75,7 @@ test('deleteDocument calls DELETE /api/documents/{key} and resolves with no valu
|
||||
|
||||
test('uploadDocument posts a multipart form with file and path fields', async () => {
|
||||
const mockResponse = { key: '产品文档/新文档.md', name: '新文档.md', size: 42 };
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
}) as unknown as typeof fetch;
|
||||
@ -83,45 +83,29 @@ test('uploadDocument posts a multipart form with file and path fields', async ()
|
||||
|
||||
const result = await uploadDocument(file, '产品文档');
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe('/api/upload');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = init.body as FormData;
|
||||
expect(body.get('file')).toBe(file);
|
||||
expect(body.get('path')).toBe('产品文档');
|
||||
expect(body.get('relative_path')).toBeNull();
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test('uploadDocument includes relative_path when uploading a file from a folder', async () => {
|
||||
const mockResponse = { key: '产品文档/子目录/新文档.md', name: '新文档.md', size: 42 };
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
}) as unknown as typeof fetch;
|
||||
const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' });
|
||||
|
||||
await uploadDocument(file, '产品文档', '子目录/新文档.md');
|
||||
|
||||
const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = init.body as FormData;
|
||||
expect(body.get('relative_path')).toBe('子目录/新文档.md');
|
||||
});
|
||||
|
||||
test('search calls GET /api/search with the q query param and returns parsed JSON', async () => {
|
||||
const mockResponse = {
|
||||
results: [
|
||||
{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' },
|
||||
],
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await search('检索');
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`);
|
||||
expect(global.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import DocumentEditor from '../../src/components/DocumentEditor';
|
||||
import { deleteDocument, getDocument, saveDocument } from '../../src/api/client';
|
||||
import { getDocument, saveDocument } from '../../src/api/client';
|
||||
|
||||
vi.mock('../../src/api/client');
|
||||
|
||||
@ -18,7 +18,6 @@ vi.mock('@monaco-editor/react', () => ({
|
||||
beforeEach(() => {
|
||||
vi.mocked(getDocument).mockReset();
|
||||
vi.mocked(saveDocument).mockReset();
|
||||
vi.mocked(deleteDocument).mockReset();
|
||||
});
|
||||
|
||||
test('shows a loading state, then loads and displays document content', async () => {
|
||||
@ -28,7 +27,7 @@ test('shows a loading state, then loads and displays document content', async ()
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
|
||||
|
||||
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
|
||||
|
||||
@ -53,7 +52,7 @@ test('typing marks the document dirty and saving clears the dirty state', async
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
|
||||
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
await user.clear(textarea);
|
||||
@ -71,133 +70,3 @@ test('typing marks the document dirty and saving clears the dirty state', async
|
||||
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
|
||||
});
|
||||
});
|
||||
|
||||
test('save failure keeps the editor and unsaved content visible, shows an error, and allows retry', async () => {
|
||||
vi.mocked(getDocument).mockResolvedValue({
|
||||
key: '产品文档/架构设计.md',
|
||||
content: '# 架构设计',
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
vi.mocked(saveDocument).mockRejectedValueOnce(new Error('network error'));
|
||||
vi.mocked(saveDocument).mockResolvedValueOnce({
|
||||
key: '产品文档/架构设计.md',
|
||||
updatedAt: '2026-08-01T10:05:00Z',
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, '# 未保存的修改');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: '保存' });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('save-error')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('document-editor')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('monaco-editor-mock')).toHaveValue('# 未保存的修改');
|
||||
expect(screen.getByTestId('save-status')).toHaveTextContent('未保存');
|
||||
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
|
||||
});
|
||||
expect(screen.queryByTestId('save-error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onDirtyChange when dirty state changes', async () => {
|
||||
vi.mocked(getDocument).mockResolvedValue({
|
||||
key: '产品文档/架构设计.md',
|
||||
content: '# 架构设计',
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
const onDirtyChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} onDeleted={vi.fn()} />);
|
||||
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
await user.type(textarea, '追加内容');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('clicking delete confirms, calls deleteDocument, and calls onDeleted on success', async () => {
|
||||
vi.mocked(getDocument).mockResolvedValue({
|
||||
key: '产品文档/架构设计.md',
|
||||
content: '# 架构设计',
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
vi.mocked(deleteDocument).mockResolvedValue(undefined);
|
||||
const onDeleted = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||
|
||||
await screen.findByTestId('monaco-editor-mock');
|
||||
const deleteButton = screen.getByRole('button', { name: '删除' });
|
||||
await user.click(deleteButton);
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('确定要删除这篇文档吗?此操作无法撤销。');
|
||||
await waitFor(() => {
|
||||
expect(deleteDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onDeleted).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('clicking delete does nothing if the confirmation is declined', async () => {
|
||||
vi.mocked(getDocument).mockResolvedValue({
|
||||
key: '产品文档/架构设计.md',
|
||||
content: '# 架构设计',
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
const onDeleted = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||
|
||||
await screen.findByTestId('monaco-editor-mock');
|
||||
const deleteButton = screen.getByRole('button', { name: '删除' });
|
||||
await user.click(deleteButton);
|
||||
|
||||
expect(deleteDocument).not.toHaveBeenCalled();
|
||||
expect(onDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows an inline error and does not call onDeleted when delete fails', async () => {
|
||||
vi.mocked(getDocument).mockResolvedValue({
|
||||
key: '产品文档/架构设计.md',
|
||||
content: '# 架构设计',
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
vi.mocked(deleteDocument).mockRejectedValue(new Error('network error'));
|
||||
const onDeleted = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||
|
||||
await screen.findByTestId('monaco-editor-mock');
|
||||
const deleteButton = screen.getByRole('button', { name: '删除' });
|
||||
await user.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-error')).toBeInTheDocument();
|
||||
});
|
||||
expect(onDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import FileExplorer from '../../src/components/FileExplorer';
|
||||
import { listDocuments } from '../../src/api/client';
|
||||
@ -67,19 +67,3 @@ test('shows an error message when listDocuments rejects', async () => {
|
||||
|
||||
expect(await screen.findByTestId('file-explorer-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('changing refreshKey re-fetches the root listing', async () => {
|
||||
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||
|
||||
const { rerender } = render(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} refreshKey={0} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listDocuments).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
rerender(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} refreshKey={1} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listDocuments).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,19 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
|
||||
import SearchBar from '../../src/components/SearchBar';
|
||||
import { search, 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} />;
|
||||
}
|
||||
import { search } from '../../src/api/client';
|
||||
|
||||
vi.mock('../../src/api/client');
|
||||
|
||||
@ -31,7 +19,7 @@ test('debounces input for 300ms before calling search', async () => {
|
||||
const onQueryChange = vi.fn();
|
||||
const onResults = vi.fn();
|
||||
|
||||
render(<SearchBar value="" onQueryChange={onQueryChange} onResults={onResults} />);
|
||||
render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: '检索' } });
|
||||
@ -46,9 +34,10 @@ test('debounces input for 300ms before calling search', async () => {
|
||||
|
||||
test('clearing the input immediately reports empty results without calling search', async () => {
|
||||
vi.mocked(search).mockResolvedValue({ results: [] });
|
||||
const onQueryChange = vi.fn();
|
||||
const onResults = vi.fn();
|
||||
|
||||
render(<ControlledSearchBar onResults={onResults} />);
|
||||
render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'x' } });
|
||||
@ -57,17 +46,3 @@ test('clearing the input immediately reports empty results without calling searc
|
||||
|
||||
expect(onResults).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
test('reflects the value prop, allowing a parent to control/reset the input', () => {
|
||||
const onQueryChange = vi.fn();
|
||||
const onResults = vi.fn();
|
||||
|
||||
const { rerender } = render(<SearchBar value="检索" onQueryChange={onQueryChange} onResults={onResults} />);
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
expect(input).toHaveValue('检索');
|
||||
|
||||
rerender(<SearchBar value="" onQueryChange={onQueryChange} onResults={onResults} />);
|
||||
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
|
||||
@ -39,44 +39,6 @@ test('selecting a file through the hidden input uploads it', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('a failed upload shows an error message and keeps the modal open for retry', async () => {
|
||||
vi.mocked(uploadDocument).mockRejectedValue(new Error('Failed to upload document: 400'));
|
||||
const onUploaded = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
|
||||
await user.click(screen.getByRole('button', { name: '上传文档' }));
|
||||
|
||||
const file = new File(['data'], '恶意程序.exe', { type: 'application/octet-stream' });
|
||||
const input = screen.getByTestId('upload-file-input');
|
||||
await user.upload(input, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('upload-error')).toHaveTextContent('Failed to upload document: 400');
|
||||
});
|
||||
expect(screen.getByTestId('upload-modal')).toBeInTheDocument();
|
||||
expect(onUploaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('selecting a folder through the folder input uploads each file with its relative path', async () => {
|
||||
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/子目录/新文档.md', name: '新文档.md', size: 8 });
|
||||
const onUploaded = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
|
||||
await user.click(screen.getByRole('button', { name: '上传文档' }));
|
||||
|
||||
const file = new File(['content'], '新文档.md', { type: 'text/markdown' });
|
||||
Object.defineProperty(file, 'webkitRelativePath', { value: '子目录/新文档.md', configurable: true });
|
||||
const input = screen.getByTestId('upload-folder-input');
|
||||
await user.upload(input, file);
|
||||
|
||||
expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档', '子目录/新文档.md');
|
||||
await waitFor(() => {
|
||||
expect(onUploaded).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('dropping a file onto the dropzone uploads it', async () => {
|
||||
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
|
||||
const onUploaded = vi.fn();
|
||||
@ -97,46 +59,3 @@ test('dropping a file onto the dropzone uploads it', async () => {
|
||||
expect(onUploaded).toHaveBeenCalledWith({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
test('dropping a folder onto the dropzone walks its entries and uploads each file with its relative path', async () => {
|
||||
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/子目录/文件.md', name: '文件.md', size: 5 });
|
||||
const onUploaded = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const file = new File(['# hi'], '文件.md', { type: 'text/markdown' });
|
||||
|
||||
const fileEntry = {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
fullPath: '/子目录/文件.md',
|
||||
file: (success: (f: File) => void) => success(file),
|
||||
};
|
||||
let readCount = 0;
|
||||
const dirReader = {
|
||||
readEntries: (success: (entries: unknown[]) => void) => {
|
||||
readCount += 1;
|
||||
success(readCount === 1 ? [fileEntry] : []);
|
||||
},
|
||||
};
|
||||
const dirEntry = {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
fullPath: '/子目录',
|
||||
createReader: () => dirReader,
|
||||
};
|
||||
const item = { webkitGetAsEntry: () => dirEntry };
|
||||
|
||||
render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
|
||||
await user.click(screen.getByRole('button', { name: '上传文档' }));
|
||||
const dropzone = screen.getByTestId('dropzone');
|
||||
|
||||
fireEvent.drop(dropzone, {
|
||||
dataTransfer: { items: [item], files: [] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档', '子目录/文件.md');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onUploaded).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user