Compare commits
10 Commits
13789cc0b4
...
4a6fdaf994
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a6fdaf994 | |||
| 4c19fad561 | |||
| d2d046802b | |||
| 360e9e5a14 | |||
| 0f0b7e9d32 | |||
| b13810d68b | |||
| a6b04c1538 | |||
| 9ef22f9f0a | |||
| 911d814a2f | |||
| 7e218b5738 |
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Docs
|
||||||
|
docs/
|
||||||
@ -3,14 +3,11 @@ import os
|
|||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.minio_endpoint = os.environ.get("MINIO_ENDPOINT", "localhost:9000")
|
self.storage_dir = os.environ.get("STORAGE_DIR", "/data/documents")
|
||||||
self.minio_access_key = os.environ.get("MINIO_ACCESS_KEY", "minioadmin")
|
|
||||||
self.minio_secret_key = os.environ.get("MINIO_SECRET_KEY", "minioadmin")
|
|
||||||
self.minio_bucket = os.environ.get("MINIO_BUCKET", "dochub")
|
|
||||||
self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700")
|
self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700")
|
||||||
self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "")
|
self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "")
|
||||||
self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents")
|
self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents")
|
||||||
self.cors_origins = os.environ.get("CORS_ORIGINS", "*").split(",")
|
self.cors_origins = os.environ.get("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@ -1,29 +1,30 @@
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
import meilisearch
|
import meilisearch
|
||||||
from minio import Minio
|
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.document_service import DocumentService
|
from app.document_service import DocumentService
|
||||||
from app.minio_client import MinioClient
|
from app.local_file_client import LocalFileClient
|
||||||
from app.search_client import SearchClient
|
from app.search_client import SearchClient
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def _get_file_client() -> LocalFileClient:
|
||||||
|
return LocalFileClient(settings.storage_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def _get_meilisearch_sdk() -> meilisearch.Client:
|
||||||
|
return meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_document_service() -> DocumentService:
|
def get_document_service() -> DocumentService:
|
||||||
minio_sdk = Minio(
|
file_client = _get_file_client()
|
||||||
settings.minio_endpoint,
|
search_client = SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index)
|
||||||
access_key=settings.minio_access_key,
|
return DocumentService(file_client, search_client)
|
||||||
secret_key=settings.minio_secret_key,
|
|
||||||
secure=False,
|
|
||||||
)
|
|
||||||
meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
|
|
||||||
minio_client = MinioClient(minio_sdk, settings.minio_bucket)
|
|
||||||
search_client = SearchClient(meili_sdk, settings.meilisearch_index)
|
|
||||||
return DocumentService(minio_client, search_client)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_search_client() -> SearchClient:
|
def get_search_client() -> SearchClient:
|
||||||
meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
|
return SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index)
|
||||||
return SearchClient(meili_sdk, settings.meilisearch_index)
|
|
||||||
|
|||||||
@ -26,6 +26,17 @@ class FileTooLargeError(Exception):
|
|||||||
super().__init__(f"file too large: max {max_size_bytes // (1024 * 1024)}MB")
|
super().__init__(f"file too large: max {max_size_bytes // (1024 * 1024)}MB")
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPathError(Exception):
|
||||||
|
def __init__(self, path: str):
|
||||||
|
self.path = path
|
||||||
|
super().__init__(f"invalid path: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_path(path: str) -> None:
|
||||||
|
if path.startswith("/") or any(segment == ".." for segment in path.split("/")):
|
||||||
|
raise InvalidPathError(path)
|
||||||
|
|
||||||
|
|
||||||
def _extension_of(key: str) -> str:
|
def _extension_of(key: str) -> str:
|
||||||
name = key.rsplit("/", 1)[-1]
|
name = key.rsplit("/", 1)[-1]
|
||||||
if "." not in name:
|
if "." not in name:
|
||||||
@ -42,13 +53,13 @@ def _guess_content_type(key: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class DocumentService:
|
class DocumentService:
|
||||||
def __init__(self, minio_client, search_client):
|
def __init__(self, file_client, search_client):
|
||||||
self._minio_client = minio_client
|
self._file_client = file_client
|
||||||
self._search_client = search_client
|
self._search_client = search_client
|
||||||
|
|
||||||
def list_documents(self, path: str) -> dict:
|
def list_documents(self, path: str) -> dict:
|
||||||
prefix = path if not path or path.endswith("/") else f"{path}/"
|
prefix = path if not path or path.endswith("/") else f"{path}/"
|
||||||
entries = self._minio_client.list_objects(prefix)
|
entries = self._file_client.list_objects(prefix)
|
||||||
folders = []
|
folders = []
|
||||||
files = []
|
files = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
@ -68,8 +79,9 @@ class DocumentService:
|
|||||||
return {"folders": folders, "files": files}
|
return {"folders": folders, "files": files}
|
||||||
|
|
||||||
def get_document(self, key: str) -> dict:
|
def get_document(self, key: str) -> dict:
|
||||||
|
_validate_path(key)
|
||||||
try:
|
try:
|
||||||
data = self._minio_client.get_object(key)
|
data = self._file_client.get_object(key)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise DocumentNotFoundError(key) from exc
|
raise DocumentNotFoundError(key) from exc
|
||||||
try:
|
try:
|
||||||
@ -94,7 +106,8 @@ class DocumentService:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
def save_document(self, key: str, content: bytes, content_type: str) -> dict:
|
def save_document(self, key: str, content: bytes, content_type: str) -> dict:
|
||||||
self._minio_client.put_object(key, content, content_type)
|
_validate_path(key)
|
||||||
|
self._file_client.put_object(key, content, content_type)
|
||||||
try:
|
try:
|
||||||
text = self._extract_text(content, content_type)
|
text = self._extract_text(content, content_type)
|
||||||
title = key.rsplit("/", 1)[-1]
|
title = key.rsplit("/", 1)[-1]
|
||||||
@ -105,13 +118,15 @@ class DocumentService:
|
|||||||
return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()}
|
return {"key": key, "updatedAt": datetime.now(timezone.utc).isoformat()}
|
||||||
|
|
||||||
def delete_document(self, key: str) -> None:
|
def delete_document(self, key: str) -> None:
|
||||||
self._minio_client.delete_object(key)
|
_validate_path(key)
|
||||||
|
self._file_client.delete_object(key)
|
||||||
try:
|
try:
|
||||||
self._search_client.delete_document(key)
|
self._search_client.delete_document(key)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to delete search index entry for %s", key)
|
logger.exception("Failed to delete search index entry for %s", key)
|
||||||
|
|
||||||
def upload_document(self, key: str, data: bytes, content_type: str) -> dict:
|
def upload_document(self, key: str, data: bytes, content_type: str) -> dict:
|
||||||
|
_validate_path(key)
|
||||||
extension = _extension_of(key)
|
extension = _extension_of(key)
|
||||||
if extension not in ALLOWED_UPLOAD_EXTENSIONS:
|
if extension not in ALLOWED_UPLOAD_EXTENSIONS:
|
||||||
raise UnsupportedFileTypeError(extension)
|
raise UnsupportedFileTypeError(extension)
|
||||||
|
|||||||
76
backend/app/local_file_client.py
Normal file
76
backend/app/local_file_client.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFileClient:
|
||||||
|
"""本地文件系统存储客户端"""
|
||||||
|
|
||||||
|
def __init__(self, base_dir: str):
|
||||||
|
self._base_dir = Path(base_dir).resolve()
|
||||||
|
self._base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _resolve_path(self, key: str) -> Path:
|
||||||
|
"""将 key 转换为文件系统路径,确保不会逃出 base_dir"""
|
||||||
|
path = (self._base_dir / key).resolve()
|
||||||
|
if not path.is_relative_to(self._base_dir):
|
||||||
|
raise ValueError(f"Path traversal attempt: {key}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def list_objects(self, prefix: str) -> list[dict]:
|
||||||
|
"""列出指定前缀下的所有对象(文件和目录)"""
|
||||||
|
prefix_path = self._base_dir / prefix if prefix else self._base_dir
|
||||||
|
|
||||||
|
if not prefix_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = []
|
||||||
|
try:
|
||||||
|
for entry in prefix_path.iterdir():
|
||||||
|
rel_path = entry.relative_to(self._base_dir)
|
||||||
|
key = str(rel_path).replace(os.sep, "/")
|
||||||
|
|
||||||
|
if entry.is_dir():
|
||||||
|
result.append({
|
||||||
|
"key": key + "/",
|
||||||
|
"size": None,
|
||||||
|
"is_dir": True,
|
||||||
|
"modifiedAt": None,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
stat = entry.stat()
|
||||||
|
result.append({
|
||||||
|
"key": key,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"is_dir": False,
|
||||||
|
"modifiedAt": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_object(self, key: str) -> bytes:
|
||||||
|
path = self._resolve_path(key)
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
raise FileNotFoundError(key)
|
||||||
|
return path.read_bytes()
|
||||||
|
|
||||||
|
def put_object(self, key: str, data: bytes, content_type: str) -> None:
|
||||||
|
path = self._resolve_path(key)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(data)
|
||||||
|
|
||||||
|
def delete_object(self, key: str) -> None:
|
||||||
|
path = self._resolve_path(key)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(key)
|
||||||
|
path.unlink()
|
||||||
|
# 清理空目录(逐层向上)
|
||||||
|
parent = path.parent
|
||||||
|
while parent != self._base_dir:
|
||||||
|
if not any(parent.iterdir()):
|
||||||
|
parent.rmdir()
|
||||||
|
parent = parent.parent
|
||||||
|
else:
|
||||||
|
break
|
||||||
@ -1,9 +1,13 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routers import documents, search
|
from app.routers import documents, search
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI(title="DocHub API")
|
app = FastAPI(title="DocHub API")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
import io
|
|
||||||
from minio.error import S3Error
|
|
||||||
|
|
||||||
|
|
||||||
class MinioClient:
|
|
||||||
def __init__(self, client, bucket: str):
|
|
||||||
self._client = client
|
|
||||||
self._bucket = bucket
|
|
||||||
|
|
||||||
def list_objects(self, prefix: str) -> list[dict]:
|
|
||||||
objects = self._client.list_objects(self._bucket, prefix=prefix, recursive=False)
|
|
||||||
result = []
|
|
||||||
for obj in objects:
|
|
||||||
result.append({
|
|
||||||
"key": obj.object_name,
|
|
||||||
"size": obj.size,
|
|
||||||
"is_dir": obj.object_name.endswith("/"),
|
|
||||||
"modifiedAt": obj.last_modified.isoformat() if obj.last_modified else None,
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
|
|
||||||
def get_object(self, key: str) -> bytes:
|
|
||||||
try:
|
|
||||||
response = self._client.get_object(self._bucket, key)
|
|
||||||
except S3Error as exc:
|
|
||||||
if exc.code == "NoSuchKey":
|
|
||||||
raise FileNotFoundError(key) from exc
|
|
||||||
raise
|
|
||||||
try:
|
|
||||||
return response.read()
|
|
||||||
finally:
|
|
||||||
response.close()
|
|
||||||
response.release_conn()
|
|
||||||
|
|
||||||
def put_object(self, key: str, data: bytes, content_type: str) -> None:
|
|
||||||
self._client.put_object(
|
|
||||||
self._bucket,
|
|
||||||
key,
|
|
||||||
io.BytesIO(data),
|
|
||||||
length=len(data),
|
|
||||||
content_type=content_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete_object(self, key: str) -> None:
|
|
||||||
self._client.remove_object(self._bucket, key)
|
|
||||||
@ -6,6 +6,7 @@ from app.document_service import (
|
|||||||
DocumentNotFoundError,
|
DocumentNotFoundError,
|
||||||
DocumentService,
|
DocumentService,
|
||||||
FileTooLargeError,
|
FileTooLargeError,
|
||||||
|
InvalidPathError,
|
||||||
UnsupportedFileTypeError,
|
UnsupportedFileTypeError,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -28,18 +29,26 @@ def get_document(key: str, service: DocumentService = Depends(get_document_servi
|
|||||||
return service.get_document(key)
|
return service.get_document(key)
|
||||||
except DocumentNotFoundError:
|
except DocumentNotFoundError:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
except InvalidPathError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/documents/{key:path}")
|
@router.put("/api/documents/{key:path}")
|
||||||
def save_document(
|
def save_document(
|
||||||
key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service)
|
key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service)
|
||||||
):
|
):
|
||||||
|
try:
|
||||||
return service.save_document(key, body.content.encode("utf-8"), body.contentType)
|
return service.save_document(key, body.content.encode("utf-8"), body.contentType)
|
||||||
|
except InvalidPathError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/documents/{key:path}", status_code=204)
|
@router.delete("/api/documents/{key:path}", status_code=204)
|
||||||
def delete_document(key: str, service: DocumentService = Depends(get_document_service)):
|
def delete_document(key: str, service: DocumentService = Depends(get_document_service)):
|
||||||
|
try:
|
||||||
service.delete_document(key)
|
service.delete_document(key)
|
||||||
|
except InvalidPathError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -47,12 +56,14 @@ def delete_document(key: str, service: DocumentService = Depends(get_document_se
|
|||||||
async def upload_document(
|
async def upload_document(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
path: str = Form(...),
|
path: str = Form(...),
|
||||||
|
relative_path: str | None = Form(None),
|
||||||
service: DocumentService = Depends(get_document_service),
|
service: DocumentService = Depends(get_document_service),
|
||||||
):
|
):
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
key = f"{path.rstrip('/')}/{file.filename}" if path else file.filename
|
name = relative_path if relative_path else file.filename
|
||||||
|
key = f"{path.rstrip('/')}/{name}" if path else name
|
||||||
content_type = file.content_type or "application/octet-stream"
|
content_type = file.content_type or "application/octet-stream"
|
||||||
try:
|
try:
|
||||||
return service.upload_document(key, data, content_type)
|
return service.upload_document(key, data, content_type)
|
||||||
except (UnsupportedFileTypeError, FileTooLargeError) as exc:
|
except (UnsupportedFileTypeError, FileTooLargeError, InvalidPathError) as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
fastapi==0.115.0
|
fastapi==0.115.0
|
||||||
uvicorn[standard]==0.30.6
|
uvicorn[standard]==0.30.6
|
||||||
minio==7.2.9
|
|
||||||
meilisearch==0.31.5
|
meilisearch==0.31.5
|
||||||
pytest==8.3.3
|
pytest==8.3.3
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
|
|||||||
17
backend/tests/test_config.py
Normal file
17
backend/tests/test_config.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_origins_defaults_to_localhost_dev_port(monkeypatch):
|
||||||
|
monkeypatch.delenv("CORS_ORIGINS", raising=False)
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
assert settings.cors_origins == ["http://localhost:5173"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_origins_can_be_overridden_to_wildcard(monkeypatch):
|
||||||
|
monkeypatch.setenv("CORS_ORIGINS", "*")
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
assert settings.cors_origins == ["*"]
|
||||||
@ -4,12 +4,13 @@ from app.document_service import (
|
|||||||
DocumentService,
|
DocumentService,
|
||||||
DocumentNotFoundError,
|
DocumentNotFoundError,
|
||||||
FileTooLargeError,
|
FileTooLargeError,
|
||||||
|
InvalidPathError,
|
||||||
MAX_UPLOAD_SIZE_BYTES,
|
MAX_UPLOAD_SIZE_BYTES,
|
||||||
UnsupportedFileTypeError,
|
UnsupportedFileTypeError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeMinioClient:
|
class FakeFileClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.entries = []
|
self.entries = []
|
||||||
self.stored = {}
|
self.stored = {}
|
||||||
@ -54,13 +55,13 @@ class FakeSearchClient:
|
|||||||
|
|
||||||
|
|
||||||
def test_list_documents_at_root_returns_folders_and_files():
|
def test_list_documents_at_root_returns_folders_and_files():
|
||||||
minio_client = FakeMinioClient()
|
file_client = FakeFileClient()
|
||||||
minio_client.entries = [
|
file_client.entries = [
|
||||||
{"key": "产品文档/", "is_dir": True, "size": 0, "modifiedAt": None},
|
{"key": "产品文档/", "is_dir": True, "size": 0, "modifiedAt": None},
|
||||||
{"key": "团队规范/", "is_dir": True, "size": 0, "modifiedAt": None},
|
{"key": "团队规范/", "is_dir": True, "size": 0, "modifiedAt": None},
|
||||||
{"key": "README.md", "is_dir": False, "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"},
|
{"key": "README.md", "is_dir": False, "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"},
|
||||||
]
|
]
|
||||||
service = DocumentService(minio_client, FakeSearchClient())
|
service = DocumentService(file_client, FakeSearchClient())
|
||||||
|
|
||||||
result = service.list_documents("")
|
result = service.list_documents("")
|
||||||
|
|
||||||
@ -71,9 +72,9 @@ def test_list_documents_at_root_returns_folders_and_files():
|
|||||||
|
|
||||||
|
|
||||||
def test_get_document_returns_content_and_content_type():
|
def test_get_document_returns_content_and_content_type():
|
||||||
minio_client = FakeMinioClient()
|
file_client = FakeFileClient()
|
||||||
minio_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 MinIO 存储文档。".encode("utf-8")
|
file_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 Garage 存储文档。".encode("utf-8")
|
||||||
service = DocumentService(minio_client, FakeSearchClient())
|
service = DocumentService(file_client, FakeSearchClient())
|
||||||
|
|
||||||
result = service.get_document("产品文档/架构设计.md")
|
result = service.get_document("产品文档/架构设计.md")
|
||||||
|
|
||||||
@ -83,17 +84,17 @@ def test_get_document_returns_content_and_content_type():
|
|||||||
|
|
||||||
|
|
||||||
def test_get_document_raises_not_found_for_missing_key():
|
def test_get_document_raises_not_found_for_missing_key():
|
||||||
service = DocumentService(FakeMinioClient(), FakeSearchClient())
|
service = DocumentService(FakeFileClient(), FakeSearchClient())
|
||||||
|
|
||||||
with pytest.raises(DocumentNotFoundError):
|
with pytest.raises(DocumentNotFoundError):
|
||||||
service.get_document("不存在/文档.md")
|
service.get_document("不存在/文档.md")
|
||||||
|
|
||||||
|
|
||||||
def test_get_document_returns_base64_content_for_binary_data_without_raising():
|
def test_get_document_returns_base64_content_for_binary_data_without_raising():
|
||||||
minio_client = FakeMinioClient()
|
file_client = FakeFileClient()
|
||||||
binary_data = b"\x89PNG\r\n\x1a\n\x00\x01\xff\xfe"
|
binary_data = b"\x89PNG\r\n\x1a\n\x00\x01\xff\xfe"
|
||||||
minio_client.stored["产品文档/图片.png"] = binary_data
|
file_client.stored["产品文档/图片.png"] = binary_data
|
||||||
service = DocumentService(minio_client, FakeSearchClient())
|
service = DocumentService(file_client, FakeSearchClient())
|
||||||
|
|
||||||
result = service.get_document("产品文档/图片.png")
|
result = service.get_document("产品文档/图片.png")
|
||||||
|
|
||||||
@ -103,105 +104,139 @@ def test_get_document_returns_base64_content_for_binary_data_without_raising():
|
|||||||
|
|
||||||
|
|
||||||
def test_get_document_returns_utf8_encoding_for_text_content():
|
def test_get_document_returns_utf8_encoding_for_text_content():
|
||||||
minio_client = FakeMinioClient()
|
file_client = FakeFileClient()
|
||||||
minio_client.stored["产品文档/架构设计.md"] = "# 架构设计".encode("utf-8")
|
file_client.stored["产品文档/架构设计.md"] = "# 架构设计".encode("utf-8")
|
||||||
service = DocumentService(minio_client, FakeSearchClient())
|
service = DocumentService(file_client, FakeSearchClient())
|
||||||
|
|
||||||
result = service.get_document("产品文档/架构设计.md")
|
result = service.get_document("产品文档/架构设计.md")
|
||||||
|
|
||||||
assert result["encoding"] == "utf-8"
|
assert result["encoding"] == "utf-8"
|
||||||
|
|
||||||
|
|
||||||
def test_save_document_writes_to_minio_then_indexes_content():
|
def test_save_document_writes_to_garage_then_indexes_content():
|
||||||
minio_client = FakeMinioClient()
|
file_client = FakeFileClient()
|
||||||
search_client = FakeSearchClient()
|
search_client = FakeSearchClient()
|
||||||
service = DocumentService(minio_client, search_client)
|
service = DocumentService(file_client, search_client)
|
||||||
content = "# 架构设计\n\nDocHub 使用 **MinIO** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8")
|
content = "# 架构设计\n\nDocHub 使用 **Garage** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8")
|
||||||
|
|
||||||
result = service.save_document("产品文档/架构设计.md", content, "text/markdown")
|
result = service.save_document("产品文档/架构设计.md", content, "text/markdown")
|
||||||
|
|
||||||
assert minio_client.stored["产品文档/架构设计.md"] == content
|
assert file_client.stored["产品文档/架构设计.md"] == content
|
||||||
assert search_client.indexed[0]["key"] == "产品文档/架构设计.md"
|
assert search_client.indexed[0]["key"] == "产品文档/架构设计.md"
|
||||||
assert search_client.indexed[0]["path"] == "产品文档"
|
assert search_client.indexed[0]["path"] == "产品文档"
|
||||||
assert search_client.indexed[0]["title"] == "架构设计.md"
|
assert search_client.indexed[0]["title"] == "架构设计.md"
|
||||||
assert "MinIO" in search_client.indexed[0]["content"]
|
assert "Garage" in search_client.indexed[0]["content"]
|
||||||
assert "#" not in search_client.indexed[0]["content"]
|
assert "#" not in search_client.indexed[0]["content"]
|
||||||
assert "key" in result and "updatedAt" in result
|
assert "key" in result and "updatedAt" in result
|
||||||
|
|
||||||
|
|
||||||
def test_save_document_does_not_fail_when_index_update_fails():
|
def test_save_document_does_not_fail_when_index_update_fails():
|
||||||
minio_client = FakeMinioClient()
|
file_client = FakeFileClient()
|
||||||
search_client = FakeSearchClient(fail_on_index=True)
|
search_client = FakeSearchClient(fail_on_index=True)
|
||||||
service = DocumentService(minio_client, search_client)
|
service = DocumentService(file_client, search_client)
|
||||||
content = b"# doc"
|
content = b"# doc"
|
||||||
|
|
||||||
result = service.save_document("产品文档/架构设计.md", content, "text/markdown")
|
result = service.save_document("产品文档/架构设计.md", content, "text/markdown")
|
||||||
|
|
||||||
assert minio_client.stored["产品文档/架构设计.md"] == content
|
assert file_client.stored["产品文档/架构设计.md"] == content
|
||||||
assert result["key"] == "产品文档/架构设计.md"
|
assert result["key"] == "产品文档/架构设计.md"
|
||||||
|
|
||||||
|
|
||||||
def test_delete_document_removes_from_minio_and_search_index():
|
def test_delete_document_removes_from_garage_and_search_index():
|
||||||
minio_client = FakeMinioClient()
|
file_client = FakeFileClient()
|
||||||
minio_client.stored["产品文档/旧文档.md"] = b"old content"
|
file_client.stored["产品文档/旧文档.md"] = b"old content"
|
||||||
search_client = FakeSearchClient()
|
search_client = FakeSearchClient()
|
||||||
service = DocumentService(minio_client, search_client)
|
service = DocumentService(file_client, search_client)
|
||||||
|
|
||||||
service.delete_document("产品文档/旧文档.md")
|
service.delete_document("产品文档/旧文档.md")
|
||||||
|
|
||||||
assert "产品文档/旧文档.md" not in minio_client.stored
|
assert "产品文档/旧文档.md" not in file_client.stored
|
||||||
assert search_client.deleted == ["产品文档/旧文档.md"]
|
assert search_client.deleted == ["产品文档/旧文档.md"]
|
||||||
|
|
||||||
|
|
||||||
def test_delete_document_does_not_fail_when_index_delete_fails():
|
def test_delete_document_does_not_fail_when_index_delete_fails():
|
||||||
minio_client = FakeMinioClient()
|
file_client = FakeFileClient()
|
||||||
minio_client.stored["产品文档/旧文档.md"] = b"old content"
|
file_client.stored["产品文档/旧文档.md"] = b"old content"
|
||||||
search_client = FakeSearchClient(fail_on_delete=True)
|
search_client = FakeSearchClient(fail_on_delete=True)
|
||||||
service = DocumentService(minio_client, search_client)
|
service = DocumentService(file_client, search_client)
|
||||||
|
|
||||||
service.delete_document("产品文档/旧文档.md")
|
service.delete_document("产品文档/旧文档.md")
|
||||||
|
|
||||||
assert "产品文档/旧文档.md" not in minio_client.stored
|
assert "产品文档/旧文档.md" not in file_client.stored
|
||||||
|
|
||||||
|
|
||||||
def test_upload_document_writes_file_and_returns_metadata():
|
def test_upload_document_writes_file_and_returns_metadata():
|
||||||
minio_client = FakeMinioClient()
|
file_client = FakeFileClient()
|
||||||
service = DocumentService(minio_client, FakeSearchClient())
|
service = DocumentService(file_client, FakeSearchClient())
|
||||||
data = b"# uploaded doc"
|
data = b"# uploaded doc"
|
||||||
|
|
||||||
result = service.upload_document("产品文档/新上传.md", data, "text/markdown")
|
result = service.upload_document("产品文档/新上传.md", data, "text/markdown")
|
||||||
|
|
||||||
assert result == {"key": "产品文档/新上传.md", "name": "新上传.md", "size": len(data)}
|
assert result == {"key": "产品文档/新上传.md", "name": "新上传.md", "size": len(data)}
|
||||||
assert minio_client.stored["产品文档/新上传.md"] == data
|
assert file_client.stored["产品文档/新上传.md"] == data
|
||||||
|
|
||||||
|
|
||||||
def test_upload_document_rejects_unsupported_file_extension():
|
def test_upload_document_rejects_unsupported_file_extension():
|
||||||
minio_client = FakeMinioClient()
|
file_client = FakeFileClient()
|
||||||
service = DocumentService(minio_client, FakeSearchClient())
|
service = DocumentService(file_client, FakeSearchClient())
|
||||||
|
|
||||||
with pytest.raises(UnsupportedFileTypeError):
|
with pytest.raises(UnsupportedFileTypeError):
|
||||||
service.upload_document("产品文档/病毒.exe", b"data", "application/octet-stream")
|
service.upload_document("产品文档/病毒.exe", b"data", "application/octet-stream")
|
||||||
|
|
||||||
assert "产品文档/病毒.exe" not in minio_client.stored
|
assert "产品文档/病毒.exe" not in file_client.stored
|
||||||
|
|
||||||
|
|
||||||
def test_upload_document_rejects_oversized_file():
|
def test_upload_document_rejects_oversized_file():
|
||||||
minio_client = FakeMinioClient()
|
file_client = FakeFileClient()
|
||||||
service = DocumentService(minio_client, FakeSearchClient())
|
service = DocumentService(file_client, FakeSearchClient())
|
||||||
data = b"x" * (MAX_UPLOAD_SIZE_BYTES + 1)
|
data = b"x" * (MAX_UPLOAD_SIZE_BYTES + 1)
|
||||||
|
|
||||||
with pytest.raises(FileTooLargeError):
|
with pytest.raises(FileTooLargeError):
|
||||||
service.upload_document("产品文档/大文件.md", data, "text/markdown")
|
service.upload_document("产品文档/大文件.md", data, "text/markdown")
|
||||||
|
|
||||||
assert "产品文档/大文件.md" not in minio_client.stored
|
assert "产品文档/大文件.md" not in file_client.stored
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_document_rejects_path_traversal_key():
|
||||||
|
service = DocumentService(FakeFileClient(), FakeSearchClient())
|
||||||
|
|
||||||
|
with pytest.raises(InvalidPathError):
|
||||||
|
service.get_document("../../etc/passwd")
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_document_rejects_path_traversal_key():
|
||||||
|
file_client = FakeFileClient()
|
||||||
|
service = DocumentService(file_client, FakeSearchClient())
|
||||||
|
|
||||||
|
with pytest.raises(InvalidPathError):
|
||||||
|
service.save_document("../../etc/passwd", b"data", "text/plain")
|
||||||
|
|
||||||
|
assert "../../etc/passwd" not in file_client.stored
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_document_rejects_path_traversal_key():
|
||||||
|
service = DocumentService(FakeFileClient(), FakeSearchClient())
|
||||||
|
|
||||||
|
with pytest.raises(InvalidPathError):
|
||||||
|
service.delete_document("../../etc/passwd")
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_document_rejects_path_traversal_key():
|
||||||
|
file_client = FakeFileClient()
|
||||||
|
service = DocumentService(file_client, FakeSearchClient())
|
||||||
|
|
||||||
|
with pytest.raises(InvalidPathError):
|
||||||
|
service.upload_document("../../etc/passwd", b"data", "text/plain")
|
||||||
|
|
||||||
|
assert "../../etc/passwd" not in file_client.stored
|
||||||
|
|
||||||
|
|
||||||
def test_upload_document_accepts_allowed_image_extension():
|
def test_upload_document_accepts_allowed_image_extension():
|
||||||
minio_client = FakeMinioClient()
|
file_client = FakeFileClient()
|
||||||
service = DocumentService(minio_client, FakeSearchClient())
|
service = DocumentService(file_client, FakeSearchClient())
|
||||||
data = b"\x89PNG\r\n\x1a\n"
|
data = b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
result = service.upload_document("产品文档/图片.png", data, "image/png")
|
result = service.upload_document("产品文档/图片.png", data, "image/png")
|
||||||
|
|
||||||
assert result == {"key": "产品文档/图片.png", "name": "图片.png", "size": len(data)}
|
assert result == {"key": "产品文档/图片.png", "name": "图片.png", "size": len(data)}
|
||||||
assert minio_client.stored["产品文档/图片.png"] == data
|
assert file_client.stored["产品文档/图片.png"] == data
|
||||||
|
|||||||
54
backend/tests/test_local_file_client.py
Normal file
54
backend/tests/test_local_file_client.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.local_file_client import LocalFileClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
return LocalFileClient(str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_and_get_object_roundtrip(client):
|
||||||
|
client.put_object("产品文档/架构设计.md", b"# hi", "text/markdown")
|
||||||
|
|
||||||
|
assert client.get_object("产品文档/架构设计.md") == b"# hi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_object_raises_file_not_found_for_missing_key(client):
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
client.get_object("不存在/文档.md")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_object_removes_file(client):
|
||||||
|
client.put_object("产品文档/旧文档.md", b"old", "text/markdown")
|
||||||
|
|
||||||
|
client.delete_object("产品文档/旧文档.md")
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
client.get_object("产品文档/旧文档.md")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_object_raises_file_not_found_for_missing_key(client):
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
client.delete_object("不存在/文档.md")
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_objects_returns_files_and_dirs_at_prefix(client):
|
||||||
|
client.put_object("产品文档/架构设计.md", b"# hi", "text/markdown")
|
||||||
|
client.put_object("团队规范/编码规范.md", b"# rules", "text/markdown")
|
||||||
|
|
||||||
|
result = client.list_objects("产品文档/")
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["key"] == "产品文档/架构设计.md"
|
||||||
|
assert result[0]["size"] == 4
|
||||||
|
assert result[0]["is_dir"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_objects_returns_empty_list_for_missing_prefix(client):
|
||||||
|
assert client.list_objects("不存在/") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_path_rejects_path_traversal(client):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
client.get_object("../../etc/passwd")
|
||||||
@ -1,108 +0,0 @@
|
|||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from minio.error import S3Error
|
|
||||||
|
|
||||||
from app.minio_client import MinioClient
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeObject:
|
|
||||||
def __init__(self, object_name, size, last_modified):
|
|
||||||
self.object_name = object_name
|
|
||||||
self.size = size
|
|
||||||
self.last_modified = last_modified
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeResponse:
|
|
||||||
def __init__(self, data: bytes):
|
|
||||||
self._data = data
|
|
||||||
self.closed = False
|
|
||||||
self.released = False
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
return self._data
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
self.closed = True
|
|
||||||
|
|
||||||
def release_conn(self):
|
|
||||||
self.released = True
|
|
||||||
|
|
||||||
|
|
||||||
class FakeMinioSDK:
|
|
||||||
def __init__(self):
|
|
||||||
self.objects = {}
|
|
||||||
self.put_calls = []
|
|
||||||
self.removed_keys = []
|
|
||||||
|
|
||||||
def list_objects(self, bucket, prefix="", recursive=False):
|
|
||||||
for key, (data, content_type, last_modified) in self.objects.items():
|
|
||||||
if key.startswith(prefix):
|
|
||||||
yield _FakeObject(key, len(data), last_modified)
|
|
||||||
|
|
||||||
def get_object(self, bucket, key):
|
|
||||||
if key not in self.objects:
|
|
||||||
raise S3Error("NoSuchKey", "Object does not exist", key, "req-1", "host-1", None)
|
|
||||||
data, _, _ = self.objects[key]
|
|
||||||
return _FakeResponse(data)
|
|
||||||
|
|
||||||
def put_object(self, bucket, key, stream, length, content_type):
|
|
||||||
data = stream.read()
|
|
||||||
self.objects[key] = (data, content_type, datetime.now(timezone.utc))
|
|
||||||
self.put_calls.append((bucket, key, content_type))
|
|
||||||
|
|
||||||
def remove_object(self, bucket, key):
|
|
||||||
del self.objects[key]
|
|
||||||
self.removed_keys.append(key)
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_objects_returns_keys_matching_prefix():
|
|
||||||
sdk = FakeMinioSDK()
|
|
||||||
sdk.objects["产品文档/架构设计.md"] = (b"# hi", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc))
|
|
||||||
sdk.objects["团队规范/编码规范.md"] = (b"# rules", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc))
|
|
||||||
client = MinioClient(sdk, bucket="dochub")
|
|
||||||
|
|
||||||
result = client.list_objects("产品文档/")
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["key"] == "产品文档/架构设计.md"
|
|
||||||
assert result[0]["size"] == 4
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_object_returns_bytes():
|
|
||||||
sdk = FakeMinioSDK()
|
|
||||||
sdk.objects["产品文档/架构设计.md"] = (b"# architecture", "text/markdown", datetime.now(timezone.utc))
|
|
||||||
client = MinioClient(sdk, bucket="dochub")
|
|
||||||
|
|
||||||
data = client.get_object("产品文档/架构设计.md")
|
|
||||||
|
|
||||||
assert data == b"# architecture"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_object_raises_file_not_found_for_missing_key():
|
|
||||||
sdk = FakeMinioSDK()
|
|
||||||
client = MinioClient(sdk, bucket="dochub")
|
|
||||||
|
|
||||||
with pytest.raises(FileNotFoundError):
|
|
||||||
client.get_object("不存在/文档.md")
|
|
||||||
|
|
||||||
|
|
||||||
def test_put_object_stores_data_with_content_type():
|
|
||||||
sdk = FakeMinioSDK()
|
|
||||||
client = MinioClient(sdk, bucket="dochub")
|
|
||||||
|
|
||||||
client.put_object("产品文档/新文档.md", b"# new doc", "text/markdown")
|
|
||||||
|
|
||||||
assert sdk.objects["产品文档/新文档.md"][0] == b"# new doc"
|
|
||||||
assert sdk.put_calls == [("dochub", "产品文档/新文档.md", "text/markdown")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_object_removes_key():
|
|
||||||
sdk = FakeMinioSDK()
|
|
||||||
sdk.objects["产品文档/旧文档.md"] = (b"old", "text/markdown", datetime.now(timezone.utc))
|
|
||||||
client = MinioClient(sdk, bucket="dochub")
|
|
||||||
|
|
||||||
client.delete_object("产品文档/旧文档.md")
|
|
||||||
|
|
||||||
assert "产品文档/旧文档.md" not in sdk.objects
|
|
||||||
assert sdk.removed_keys == ["产品文档/旧文档.md"]
|
|
||||||
@ -3,7 +3,12 @@ from fastapi.testclient import TestClient
|
|||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.dependencies import get_document_service
|
from app.dependencies import get_document_service
|
||||||
from app.document_service import DocumentNotFoundError, FileTooLargeError, UnsupportedFileTypeError
|
from app.document_service import (
|
||||||
|
DocumentNotFoundError,
|
||||||
|
FileTooLargeError,
|
||||||
|
InvalidPathError,
|
||||||
|
UnsupportedFileTypeError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeDocumentService:
|
class FakeDocumentService:
|
||||||
@ -23,16 +28,24 @@ class FakeDocumentService:
|
|||||||
def get_document(self, key):
|
def get_document(self, key):
|
||||||
if key == "不存在/文档.md":
|
if key == "不存在/文档.md":
|
||||||
raise DocumentNotFoundError(key)
|
raise DocumentNotFoundError(key)
|
||||||
|
if ".." in key.split("/"):
|
||||||
|
raise InvalidPathError(key)
|
||||||
return {"key": key, "content": "# 架构设计\n\n内容", "contentType": "text/markdown"}
|
return {"key": key, "content": "# 架构设计\n\n内容", "contentType": "text/markdown"}
|
||||||
|
|
||||||
def save_document(self, key, content, content_type):
|
def save_document(self, key, content, content_type):
|
||||||
|
if ".." in key.split("/"):
|
||||||
|
raise InvalidPathError(key)
|
||||||
self.saved.append((key, content, content_type))
|
self.saved.append((key, content, content_type))
|
||||||
return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"}
|
return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"}
|
||||||
|
|
||||||
def delete_document(self, key):
|
def delete_document(self, key):
|
||||||
|
if ".." in key.split("/"):
|
||||||
|
raise InvalidPathError(key)
|
||||||
self.deleted.append(key)
|
self.deleted.append(key)
|
||||||
|
|
||||||
def upload_document(self, key, data, content_type):
|
def upload_document(self, key, data, content_type):
|
||||||
|
if ".." in key.split("/"):
|
||||||
|
raise InvalidPathError(key)
|
||||||
if key.endswith(".exe"):
|
if key.endswith(".exe"):
|
||||||
raise UnsupportedFileTypeError(".exe")
|
raise UnsupportedFileTypeError(".exe")
|
||||||
if len(data) > 1024:
|
if len(data) > 1024:
|
||||||
@ -101,6 +114,17 @@ def test_upload_document_returns_201_with_metadata(client):
|
|||||||
assert body["size"] == len(b"# new content")
|
assert body["size"] == len(b"# new content")
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_document_uses_relative_path_to_preserve_folder_structure(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/upload",
|
||||||
|
data={"path": "产品文档", "relative_path": "子目录/新文档.md"},
|
||||||
|
files={"file": ("新文档.md", b"# new content", "text/markdown")},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
body = response.json()
|
||||||
|
assert body["key"] == "产品文档/子目录/新文档.md"
|
||||||
|
|
||||||
|
|
||||||
def test_upload_document_returns_400_for_unsupported_file_type(client):
|
def test_upload_document_returns_400_for_unsupported_file_type(client):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/upload",
|
"/api/upload",
|
||||||
@ -119,3 +143,30 @@ def test_upload_document_returns_400_for_oversized_file(client):
|
|||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "file too large" in response.json()["detail"]
|
assert "file too large" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_document_returns_400_for_path_traversal_key(client):
|
||||||
|
response = client.get("/api/documents/..%2F..%2Fetc%2Fpasswd")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_document_returns_400_for_path_traversal_key(client):
|
||||||
|
response = client.put(
|
||||||
|
"/api/documents/..%2F..%2Fetc%2Fpasswd",
|
||||||
|
json={"content": "x", "contentType": "text/plain"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_document_returns_400_for_path_traversal_key(client):
|
||||||
|
response = client.delete("/api/documents/..%2F..%2Fetc%2Fpasswd")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_document_returns_400_for_path_traversal_in_path(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/upload",
|
||||||
|
data={"path": "../../etc"},
|
||||||
|
files={"file": ("passwd.md", b"data", "text/markdown")},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|||||||
@ -48,5 +48,5 @@ def test_search_returns_empty_results_for_no_match(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_search_passes_query_string_to_search_client(client):
|
def test_search_passes_query_string_to_search_client(client):
|
||||||
client.get("/api/search", params={"q": "MinIO"})
|
client.get("/api/search", params={"q": "Garage"})
|
||||||
assert client.fake_client.queries == ["MinIO"]
|
assert client.fake_client.queries == ["Garage"]
|
||||||
|
|||||||
@ -40,7 +40,7 @@ def test_index_document_adds_document_with_encoded_id():
|
|||||||
client = SearchClient(sdk, index_name="documents")
|
client = SearchClient(sdk, index_name="documents")
|
||||||
|
|
||||||
client.index_document(
|
client.index_document(
|
||||||
"产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 MinIO 存储文档"
|
"产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 Garage 存储文档"
|
||||||
)
|
)
|
||||||
|
|
||||||
index = sdk.indexes["documents"]
|
index = sdk.indexes["documents"]
|
||||||
@ -49,7 +49,7 @@ def test_index_document_adds_document_with_encoded_id():
|
|||||||
assert stored["key"] == "产品文档/架构设计.md"
|
assert stored["key"] == "产品文档/架构设计.md"
|
||||||
assert stored["path"] == "产品文档"
|
assert stored["path"] == "产品文档"
|
||||||
assert stored["title"] == "架构设计.md"
|
assert stored["title"] == "架构设计.md"
|
||||||
assert stored["content"] == "DocHub 使用 MinIO 存储文档"
|
assert stored["content"] == "DocHub 使用 Garage 存储文档"
|
||||||
|
|
||||||
|
|
||||||
def test_delete_document_removes_document():
|
def test_delete_document_removes_document():
|
||||||
|
|||||||
@ -6,7 +6,7 @@ def test_html_to_text_strips_tags():
|
|||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
<h1>架构设计</h1>
|
<h1>架构设计</h1>
|
||||||
<p>DocHub 是一个基于 <strong>MinIO</strong> 的文档管理系统。</p>
|
<p>DocHub 是一个基于 <strong>Garage</strong> 的文档管理系统。</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>浏览文档</li>
|
<li>浏览文档</li>
|
||||||
<li>全文检索</li>
|
<li>全文检索</li>
|
||||||
@ -16,7 +16,7 @@ def test_html_to_text_strips_tags():
|
|||||||
"""
|
"""
|
||||||
result = html_to_text(html)
|
result = html_to_text(html)
|
||||||
assert "架构设计" in result
|
assert "架构设计" in result
|
||||||
assert "DocHub 是一个基于 MinIO 的文档管理系统。" in result
|
assert "DocHub 是一个基于 Garage 的文档管理系统。" in result
|
||||||
assert "浏览文档" in result
|
assert "浏览文档" in result
|
||||||
assert "<h1>" not in result
|
assert "<h1>" not in result
|
||||||
assert "<strong>" not in result
|
assert "<strong>" not in result
|
||||||
@ -33,7 +33,7 @@ def test_markdown_to_text_strips_headers_and_emphasis():
|
|||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
|
|
||||||
DocHub 是一个 **基于 MinIO** 的 _文档管理系统_,支持全文检索。
|
DocHub 是一个 **基于 Garage** 的 _文档管理系统_,支持全文检索。
|
||||||
"""
|
"""
|
||||||
result = markdown_to_text(markdown)
|
result = markdown_to_text(markdown)
|
||||||
assert "#" not in result
|
assert "#" not in result
|
||||||
@ -41,21 +41,21 @@ DocHub 是一个 **基于 MinIO** 的 _文档管理系统_,支持全文检索
|
|||||||
assert "_" not in result
|
assert "_" not in result
|
||||||
assert "架构设计" in result
|
assert "架构设计" in result
|
||||||
assert "背景" in result
|
assert "背景" in result
|
||||||
assert "基于 MinIO" in result
|
assert "基于 Garage" in result
|
||||||
assert "文档管理系统" in result
|
assert "文档管理系统" in result
|
||||||
|
|
||||||
|
|
||||||
def test_markdown_to_text_strips_links_and_list_markers():
|
def test_markdown_to_text_strips_links_and_list_markers():
|
||||||
markdown = """## 功能列表
|
markdown = """## 功能列表
|
||||||
|
|
||||||
- 浏览 [MinIO](https://min.io) 中存储的文档
|
- 浏览 [Garage](https://garagehq.deuxfleurs.fr) 中存储的文档
|
||||||
- 支持 [Meilisearch](https://www.meilisearch.com) 全文检索
|
- 支持 [Meilisearch](https://www.meilisearch.com) 全文检索
|
||||||
1. 在线编辑并保存
|
1. 在线编辑并保存
|
||||||
"""
|
"""
|
||||||
result = markdown_to_text(markdown)
|
result = markdown_to_text(markdown)
|
||||||
assert "[" not in result
|
assert "[" not in result
|
||||||
assert "](" not in result
|
assert "](" not in result
|
||||||
assert "浏览 MinIO 中存储的文档" in result
|
assert "浏览 Garage 中存储的文档" in result
|
||||||
assert "支持 Meilisearch 全文检索" in result
|
assert "支持 Meilisearch 全文检索" in result
|
||||||
assert "在线编辑并保存" in result
|
assert "在线编辑并保存" in result
|
||||||
|
|
||||||
|
|||||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
environment:
|
||||||
|
STORAGE_DIR: /data/documents
|
||||||
|
MEILISEARCH_HOST: http://meilisearch:7700
|
||||||
|
MEILISEARCH_API_KEY: ${MEILISEARCH_API_KEY:-}
|
||||||
|
MEILISEARCH_INDEX: ${MEILISEARCH_INDEX:-documents}
|
||||||
|
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080}
|
||||||
|
volumes:
|
||||||
|
- ${STORAGE_HOST_DIR:-../dochub-data}:/data/documents
|
||||||
|
depends_on:
|
||||||
|
- meilisearch
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
args:
|
||||||
|
VITE_API_BASE_URL: ""
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
meilisearch:
|
||||||
|
image: getmeili/meilisearch:v1.10
|
||||||
|
environment:
|
||||||
|
MEILI_MASTER_KEY: ${MEILISEARCH_API_KEY:-}
|
||||||
|
MEILI_NO_ANALYTICS: "true"
|
||||||
|
volumes:
|
||||||
|
- meilisearch-data:/meili_data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
meilisearch-data:
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,153 +0,0 @@
|
|||||||
# DocHub 设计文档
|
|
||||||
|
|
||||||
## 背景与目标
|
|
||||||
|
|
||||||
小团队(10 人以下)需要一个基于 MinIO 的文档管理系统,支持:
|
|
||||||
- 浏览 MinIO 中存储的文档(主要是 Markdown / HTML,图片为次要内容)
|
|
||||||
- 全文内容检索(不只是文件名搜索)
|
|
||||||
- 在线编辑文档内容并保存
|
|
||||||
- 上传新文档
|
|
||||||
|
|
||||||
无需用户登录和权限管理(内部工具,信任团队内所有成员)。
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
| 层 | 选型 | 理由 |
|
|
||||||
|----|------|------|
|
|
||||||
| 前端 | React + Vite + Monaco Editor | Monaco 提供代码级编辑体验(语法高亮、查找替换),适合 Markdown/HTML 源码编辑 |
|
|
||||||
| 后端 | Python + FastAPI | 团队熟悉度高,异步 IO 足够应对 MinIO/Meilisearch 网络调用瓶颈,生态利于后续扩展(文档解析、AI 摘要等) |
|
|
||||||
| 存储 | MinIO(独立部署) | S3 兼容对象存储,文档内容和元数据(大小/时间/路径)都存在这里,不引入额外数据库 |
|
|
||||||
| 检索 | Meilisearch | 轻量、内置中文分词、MIT 协议、Docker 一条命令部署,检索延迟低 |
|
|
||||||
|
|
||||||
明确不引入的组件:
|
|
||||||
- 关系型数据库(内容和元数据用 MinIO 承载,索引用 Meilisearch 承载,当前需求下够用)
|
|
||||||
- 用户认证/权限系统(团队内部工具,暂不需要)
|
|
||||||
|
|
||||||
## 架构
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐
|
|
||||||
HTTPS │ nginx │
|
|
||||||
───────────▶ │ (反向代理) │
|
|
||||||
└──────┬──────┘
|
|
||||||
│
|
|
||||||
┌────────────┼────────────┐
|
|
||||||
▼ ▼ ▼
|
|
||||||
app.domain api.domain minio.domain(可选)
|
|
||||||
│ │
|
|
||||||
┌───────▼───────┐ ┌──▼──────────────┐
|
|
||||||
│ React 前端 │ │ FastAPI 后端 │
|
|
||||||
│ Vite + Monaco│ │ (Python) │
|
|
||||||
└───────────────┘ └───┬──────────┬───┘
|
|
||||||
│ │
|
|
||||||
S3 API │ │ REST API
|
|
||||||
▼ ▼
|
|
||||||
┌─────────────┐ ┌──────────────┐
|
|
||||||
│ MinIO │ │ Meilisearch │
|
|
||||||
│ (独立部署) │ │ (与后端同机) │
|
|
||||||
└─────────────┘ └──────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**部署形态**:两套独立的 docker-compose,各自独立生命周期:
|
|
||||||
- `minio/docker-compose.yml` — 仅 MinIO,独立服务器或独立栈,长期稳定运行,可被其他项目复用
|
|
||||||
- `app/docker-compose.yml` — frontend + backend + meilisearch,可整体重启/升级而不影响已存储的数据
|
|
||||||
|
|
||||||
两套 compose 之间通过共享 Docker 网络或宿主机 IP 通信(后端配置 MinIO endpoint 即可,代码不感知部署形态差异,因为走的是标准 S3 API)。
|
|
||||||
|
|
||||||
**域名与端口**:通过 nginx 反向代理 + 子域名区分服务,无需在域名上绑定端口:
|
|
||||||
```
|
|
||||||
app.domain → React 前端
|
|
||||||
api.domain → FastAPI 后端
|
|
||||||
minio.domain → MinIO 控制台(可选对外)
|
|
||||||
```
|
|
||||||
Meilisearch 仅供后端内网访问,不对外暴露子域名。
|
|
||||||
|
|
||||||
## 数据流
|
|
||||||
|
|
||||||
**上传文档**
|
|
||||||
```
|
|
||||||
前端上传文件 → 后端 PUT 到 MinIO
|
|
||||||
→ 后端解析内容(Markdown/HTML 转纯文本)
|
|
||||||
→ 推送索引到 Meilisearch
|
|
||||||
→ 返回成功
|
|
||||||
```
|
|
||||||
|
|
||||||
**浏览文件列表**
|
|
||||||
```
|
|
||||||
前端请求文件树 → 后端调用 MinIO ListObjects → 返回文件夹/文件结构
|
|
||||||
```
|
|
||||||
|
|
||||||
**查看/编辑文档**
|
|
||||||
```
|
|
||||||
点击文件 → 后端从 MinIO GetObject 取内容 → 前端 Monaco Editor 加载展示
|
|
||||||
保存编辑 → 后端覆盖写入 MinIO → 同步更新 Meilisearch 索引
|
|
||||||
```
|
|
||||||
|
|
||||||
**全局搜索**
|
|
||||||
```
|
|
||||||
输入关键词 → 后端转发查询到 Meilisearch
|
|
||||||
→ 返回匹配文档(含高亮片段)
|
|
||||||
→ 前端展示结果列表,点击跳转到对应文档
|
|
||||||
```
|
|
||||||
|
|
||||||
**删除文档**
|
|
||||||
```
|
|
||||||
后端从 MinIO 删除对象 → 同步从 Meilisearch 删除对应索引
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键决策:索引更新采用后端同步处理**,即写完 MinIO 后立即在同一请求中更新 Meilisearch 索引,不引入 MinIO webhook 异步机制。理由:团队规模小、写入量低,同步更新逻辑更简单、更容易保证一致性,避免维护额外的 webhook 消费者服务。
|
|
||||||
|
|
||||||
## 模块设计
|
|
||||||
|
|
||||||
### 前端(React + Vite + Monaco Editor)
|
|
||||||
|
|
||||||
| 组件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `FileExplorer` | 左侧文件树,展示文件夹/文件,支持点击导航 |
|
|
||||||
| `DocumentViewer/Editor` | 主区域,用 Monaco 加载文档内容,支持编辑与保存 |
|
|
||||||
| `SearchBar` | 顶部全局搜索框,输入即查(debounce) |
|
|
||||||
| `SearchResults` | 搜索结果列表,展示高亮匹配片段,点击跳转对应文档 |
|
|
||||||
| `UploadButton` | 拖拽或选择文件上传 |
|
|
||||||
| `apiClient` | 封装对后端的 fetch 调用(documents CRUD + search),是前端与后端之间的唯一边界 |
|
|
||||||
|
|
||||||
### 后端(FastAPI)
|
|
||||||
|
|
||||||
API 层:
|
|
||||||
|
|
||||||
| 接口 | 方法 | 职责 |
|
|
||||||
|------|------|------|
|
|
||||||
| `/api/documents` | GET | 列出文件树(基于 MinIO ListObjects) |
|
|
||||||
| `/api/documents/{key}` | GET | 读取单个文档内容 |
|
|
||||||
| `/api/documents/{key}` | PUT | 创建/覆盖文档(写 MinIO + 更新索引) |
|
|
||||||
| `/api/documents/{key}` | DELETE | 删除文档(删 MinIO + 删索引) |
|
|
||||||
| `/api/upload` | POST | 处理文件上传(multipart) |
|
|
||||||
| `/api/search` | GET | 转发查询到 Meilisearch,返回结果 |
|
|
||||||
|
|
||||||
内部模块划分(各模块单一职责,可独立测试):
|
|
||||||
- `minio_client.py` — 封装 MinIO 操作(list/get/put/delete),只负责与 MinIO 交互,不含业务逻辑
|
|
||||||
- `search_client.py` — 封装 Meilisearch 操作(index/update/delete/search),只负责与 Meilisearch 交互
|
|
||||||
- `document_service.py` — 业务逻辑层,协调 `minio_client` 和 `search_client`(保存文档先写 MinIO 再更新索引;删除时顺序相反),并负责 Markdown/HTML 转纯文本供索引使用
|
|
||||||
- `routes/documents.py`、`routes/search.py` — API 路由层,只做参数校验和调用 service,不直接触碰 MinIO/Meilisearch 客户端
|
|
||||||
|
|
||||||
依赖方向:`routes` → `document_service` → `minio_client` / `search_client`。上层不了解下层实现细节,替换 MinIO 为其他 S3 兼容存储或替换 Meilisearch 为其他搜索引擎时,只需改动对应的 client 模块。
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
- MinIO 写入成功但索引更新失败:记录日志,不阻塞用户操作(文档已保存成功),后续可手动触发全量重建索引
|
|
||||||
- 请求的文档不存在:返回 404
|
|
||||||
- 上传文件类型/大小校验:在后端 `document_service` 层做校验(限制非文档类型或超大文件),拒绝时返回明确错误信息
|
|
||||||
|
|
||||||
## 测试策略
|
|
||||||
|
|
||||||
- 后端:pytest 测试 `document_service` 业务逻辑,mock `minio_client` 和 `search_client`,覆盖保存/删除时序、索引失败降级等场景
|
|
||||||
- 前端:暂不强制要求自动化测试覆盖(小型内部工具,手动验证优先级更高)
|
|
||||||
|
|
||||||
## 部署资源参考
|
|
||||||
|
|
||||||
学习/小团队场景下,DocHub 各服务的大致资源占用(供部署规划参考,非强制要求):
|
|
||||||
- MinIO:约 256-512MB 内存
|
|
||||||
- Meilisearch:约 512MB-1GB 内存(随索引文档量增长)
|
|
||||||
- FastAPI 后端:约 256-512MB 内存
|
|
||||||
- React/nginx 前端:约 128-256MB 内存
|
|
||||||
|
|
||||||
4 核 / 4GB 内存足以运行 DocHub 全部服务;若与其他自建服务(如 Gitea、数据库)共享同一台机器,建议预留合计 8GB 以上内存。
|
|
||||||
3559
frontend/package-lock.json
generated
Normal file
3559
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -10,37 +10,85 @@ export default function App() {
|
|||||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [uploadPath] = useState('');
|
||||||
|
|
||||||
|
function confirmDiscardIfDirty(): boolean {
|
||||||
|
if (!isDirty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return window.confirm('有未保存的修改,确定要离开吗?');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectFile(key: string) {
|
||||||
|
if (!confirmDiscardIfDirty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
function handleJumpToResult(key: string) {
|
function handleJumpToResult(key: string) {
|
||||||
|
if (!confirmDiscardIfDirty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSelectedKey(key);
|
setSelectedKey(key);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQueryChange(query: string) {
|
||||||
|
setSearchQuery(query);
|
||||||
|
setIsSearching(query.trim() !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResults(results: SearchResult[]) {
|
||||||
|
setSearchResults(results);
|
||||||
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUploaded(result: UploadDocumentResponse) {
|
function handleUploaded(result: UploadDocumentResponse) {
|
||||||
setSelectedKey(result.key);
|
setSelectedKey(result.key);
|
||||||
|
setRefreshKey((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleted() {
|
||||||
|
setSelectedKey(null);
|
||||||
|
setRefreshKey((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
<h1>DocHub</h1>
|
<h1 className="wordmark" aria-label="DocHub">
|
||||||
<SearchBar onQueryChange={setSearchQuery} onResults={setSearchResults} />
|
DocHub <span className="subtitle">档案 · 检索</span>
|
||||||
<UploadButton path="" onUploaded={handleUploaded} />
|
</h1>
|
||||||
|
<SearchBar value={searchQuery} onQueryChange={handleQueryChange} onResults={handleResults} />
|
||||||
|
<UploadButton path={uploadPath} onUploaded={handleUploaded} />
|
||||||
</div>
|
</div>
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<FileExplorer onSelectFile={setSelectedKey} selectedKey={selectedKey} />
|
<FileExplorer onSelectFile={handleSelectFile} selectedKey={selectedKey} refreshKey={refreshKey} />
|
||||||
<div className="main" data-testid="main-area">
|
<div className="main" data-testid="main-area">
|
||||||
{selectedKey ? (
|
{selectedKey ? (
|
||||||
<DocumentEditor documentKey={selectedKey} />
|
<DocumentEditor documentKey={selectedKey} onDirtyChange={setIsDirty} onDeleted={handleDeleted} />
|
||||||
) : (
|
) : (
|
||||||
<div data-testid="empty-state">未选择文档</div>
|
<div className="empty-state" data-testid="empty-state">
|
||||||
|
未选择文档
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{searchQuery !== '' && (
|
{searchQuery !== '' && !isSearching && (
|
||||||
|
<div className="search-overlay">
|
||||||
<SearchResults query={searchQuery} results={searchResults} onJumpToResult={handleJumpToResult} />
|
<SearchResults query={searchQuery} results={searchResults} onJumpToResult={handleJumpToResult} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="statusbar">
|
||||||
|
<span>DOCHUB</span>
|
||||||
|
<span>UTF-8</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,10 +77,17 @@ export interface UploadDocumentResponse {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadDocument(file: File, path: string): Promise<UploadDocumentResponse> {
|
export async function uploadDocument(
|
||||||
|
file: File,
|
||||||
|
path: string,
|
||||||
|
relativePath?: string,
|
||||||
|
): Promise<UploadDocumentResponse> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('path', path);
|
formData.append('path', path);
|
||||||
|
if (relativePath) {
|
||||||
|
formData.append('relative_path', relativePath);
|
||||||
|
}
|
||||||
const response = await fetch(`${API_BASE_URL}/api/upload`, {
|
const response = await fetch(`${API_BASE_URL}/api/upload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
|
|||||||
@ -1,22 +1,28 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import { getDocument, saveDocument } from '../api/client';
|
import { deleteDocument, getDocument, saveDocument } from '../api/client';
|
||||||
|
|
||||||
interface DocumentEditorProps {
|
interface DocumentEditorProps {
|
||||||
documentKey: string;
|
documentKey: string;
|
||||||
|
onDirtyChange?: (isDirty: boolean) => void;
|
||||||
|
onDeleted: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
|
export default function DocumentEditor({ documentKey, onDirtyChange, onDeleted }: DocumentEditorProps) {
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [originalContent, setOriginalContent] = useState('');
|
const [originalContent, setOriginalContent] = useState('');
|
||||||
const [contentType, setContentType] = useState('text/markdown');
|
const [contentType, setContentType] = useState('text/markdown');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setLoadError(null);
|
||||||
|
setSaveError(null);
|
||||||
getDocument(documentKey)
|
getDocument(documentKey)
|
||||||
.then((doc) => {
|
.then((doc) => {
|
||||||
setContent(doc.content);
|
setContent(doc.content);
|
||||||
@ -25,47 +31,98 @@ export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setError('无法加载文档');
|
setLoadError('无法加载文档');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [documentKey]);
|
}, [documentKey]);
|
||||||
|
|
||||||
const isDirty = content !== originalContent;
|
const isDirty = content !== originalContent;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChange?.(isDirty);
|
||||||
|
}, [isDirty, onDirtyChange]);
|
||||||
|
|
||||||
function handleChange(value: string | undefined) {
|
function handleChange(value: string | undefined) {
|
||||||
setContent(value ?? '');
|
setContent(value ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
saveDocument(documentKey, content, contentType)
|
saveDocument(documentKey, content, contentType)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setOriginalContent(content);
|
setOriginalContent(content);
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setError('保存失败');
|
setSaveError('保存失败,请重试');
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!window.confirm('确定要删除这篇文档吗?此操作无法撤销。')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleting(true);
|
||||||
|
setDeleteError(null);
|
||||||
|
deleteDocument(documentKey)
|
||||||
|
.then(() => {
|
||||||
|
setDeleting(false);
|
||||||
|
onDeleted();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setDeleteError('删除失败,请重试');
|
||||||
|
setDeleting(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div data-testid="document-editor-loading">加载中…</div>;
|
return <div data-testid="document-editor-loading">加载中…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (loadError) {
|
||||||
return <div data-testid="document-editor-error">{error}</div>;
|
return <div data-testid="document-editor-error">{loadError}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="document-editor">
|
<div data-testid="document-editor">
|
||||||
<div className="doc-header">
|
<div className="doc-header">
|
||||||
<span data-testid="save-status">{isDirty ? '未保存' : '已归档'}</span>
|
<div className="doc-path">
|
||||||
<button onClick={handleSave} disabled={!isDirty || saving}>
|
<span className="current">{documentKey}</span>
|
||||||
|
</div>
|
||||||
|
<div className="doc-actions">
|
||||||
|
<span className={isDirty ? 'save-status dirty' : 'save-status saved'} data-testid="save-status">
|
||||||
|
<span className="dot" />
|
||||||
|
{isDirty ? '未保存' : '已归档'}
|
||||||
|
</span>
|
||||||
|
{saveError && (
|
||||||
|
<span className="save-error" data-testid="save-error">
|
||||||
|
{saveError}
|
||||||
|
<button onClick={() => setSaveError(null)} aria-label="关闭错误提示">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{deleteError && (
|
||||||
|
<span className="save-error" data-testid="delete-error">
|
||||||
|
{deleteError}
|
||||||
|
<button onClick={() => setDeleteError(null)} aria-label="关闭错误提示">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button className="btn-secondary" onClick={handleDelete} disabled={deleting}>
|
||||||
|
{deleting ? '删除中…' : '删除'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-save" onClick={handleSave} disabled={!isDirty || saving}>
|
||||||
{saving ? '保存中…' : '保存'}
|
{saving ? '保存中…' : '保存'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="editor-area">
|
||||||
<Editor value={content} onChange={handleChange} language={contentType === 'text/markdown' ? 'markdown' : 'html'} />
|
<Editor value={content} onChange={handleChange} language={contentType === 'text/markdown' ? 'markdown' : 'html'} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,10 @@ import { listDocuments, type DocumentFile } from '../api/client';
|
|||||||
interface FileExplorerProps {
|
interface FileExplorerProps {
|
||||||
onSelectFile: (key: string) => void;
|
onSelectFile: (key: string) => void;
|
||||||
selectedKey?: string | null;
|
selectedKey?: string | null;
|
||||||
|
refreshKey?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorerProps) {
|
export default function FileExplorer({ onSelectFile, selectedKey, refreshKey }: FileExplorerProps) {
|
||||||
const [folders, setFolders] = useState<string[]>([]);
|
const [folders, setFolders] = useState<string[]>([]);
|
||||||
const [files, setFiles] = useState<DocumentFile[]>([]);
|
const [files, setFiles] = useState<DocumentFile[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -18,7 +19,7 @@ export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorer
|
|||||||
setFiles(data.files);
|
setFiles(data.files);
|
||||||
})
|
})
|
||||||
.catch(() => setError('无法加载文档目录'));
|
.catch(() => setError('无法加载文档目录'));
|
||||||
}, []);
|
}, [refreshKey]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div data-testid="file-explorer-error">{error}</div>;
|
return <div data-testid="file-explorer-error">{error}</div>;
|
||||||
@ -48,10 +49,14 @@ function FileRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid={`file-${file.key}`}
|
data-testid={`file-${file.key}`}
|
||||||
className={selectedKey === file.key ? 'file-row active' : 'file-row'}
|
className={selectedKey === file.key ? 'tree-item file-row active' : 'tree-item file-row'}
|
||||||
onClick={() => onSelectFile(file.key)}
|
onClick={() => onSelectFile(file.key)}
|
||||||
>
|
>
|
||||||
{file.name}
|
<svg className="file-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||||
|
<path d="M14 2v6h6" />
|
||||||
|
</svg>
|
||||||
|
<span className="filename">{file.name}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -85,7 +90,12 @@ function FolderNode({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div data-testid={`folder-${path}`} className="folder" onClick={toggle}>
|
<div data-testid={`folder-${path}`} className="tree-item folder" onClick={toggle}>
|
||||||
|
<span className={expanded ? 'chevron open' : 'chevron'}>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { search, type SearchResult } from '../api/client';
|
import { search, type SearchResult } from '../api/client';
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
|
value: string;
|
||||||
onQueryChange: (query: string) => void;
|
onQueryChange: (query: string) => void;
|
||||||
onResults: (results: SearchResult[]) => void;
|
onResults: (results: SearchResult[]) => void;
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) {
|
export default function SearchBar({ value, onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) {
|
||||||
const [value, setValue] = useState('');
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -21,7 +21,6 @@ export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }
|
|||||||
|
|
||||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const nextValue = event.target.value;
|
const nextValue = event.target.value;
|
||||||
setValue(nextValue);
|
|
||||||
onQueryChange(nextValue);
|
onQueryChange(nextValue);
|
||||||
|
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
@ -41,6 +40,11 @@ export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="search-box">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="检索全部文档内容…"
|
placeholder="检索全部文档内容…"
|
||||||
@ -48,5 +52,6 @@ export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
aria-label="搜索文档"
|
aria-label="搜索文档"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,20 +8,34 @@ interface SearchResultsProps {
|
|||||||
|
|
||||||
export default function SearchResults({ results, query, onJumpToResult }: SearchResultsProps) {
|
export default function SearchResults({ results, query, onJumpToResult }: SearchResultsProps) {
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
return <div data-testid="search-no-results">没有找到匹配的文档</div>;
|
return (
|
||||||
|
<div className="no-results" data-testid="search-no-results">
|
||||||
|
<span className="serif">没有找到匹配的文档</span>
|
||||||
|
换个关键词试试,或检查拼写
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="search-results">
|
<div data-testid="search-results">
|
||||||
<div data-testid="search-results-count">
|
<div className="search-results-header" data-testid="search-results-count">
|
||||||
{results.length} 条与「{query}」相关的结果
|
<span className="big-count">{results.length}</span> 条与「{query}」相关的结果
|
||||||
</div>
|
</div>
|
||||||
{results.map((result) => (
|
{results.map((result) => (
|
||||||
<div key={result.key} data-testid={`search-result-${result.key}`} onClick={() => onJumpToResult(result.key)}>
|
<div
|
||||||
<div className="result-title">{result.title}</div>
|
key={result.key}
|
||||||
<div className="result-path">{result.path}</div>
|
className="result-item"
|
||||||
|
data-testid={`search-result-${result.key}`}
|
||||||
|
onClick={() => onJumpToResult(result.key)}
|
||||||
|
>
|
||||||
|
<span className="catalog-tag">{result.path}</span>
|
||||||
|
<div className="result-body">
|
||||||
|
<div className="result-path">
|
||||||
|
<span className="result-title">{result.title}</span>
|
||||||
|
</div>
|
||||||
<div className="result-snippet" dangerouslySetInnerHTML={{ __html: result.snippet }} />
|
<div className="result-snippet" dangerouslySetInnerHTML={{ __html: result.snippet }} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { uploadDocument, type UploadDocumentResponse } from '../api/client';
|
import { uploadDocument, type UploadDocumentResponse } from '../api/client';
|
||||||
|
import { readFileEntries, type FileWithRelativePath } from '../lib/fileSystemEntry';
|
||||||
|
|
||||||
interface UploadButtonProps {
|
interface UploadButtonProps {
|
||||||
path: string;
|
path: string;
|
||||||
@ -9,7 +10,16 @@ interface UploadButtonProps {
|
|||||||
export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const folderInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
function setFolderInputRef(node: HTMLInputElement | null) {
|
||||||
|
folderInputRef.current = node;
|
||||||
|
if (node) {
|
||||||
|
node.setAttribute('webkitdirectory', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openModal() {
|
function openModal() {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
@ -18,14 +28,25 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
|||||||
function closeModal() {
|
function closeModal() {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
|
setUploadError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadOne(entry: FileWithRelativePath) {
|
||||||
|
setUploadError(null);
|
||||||
|
const promise = entry.relativePath
|
||||||
|
? uploadDocument(entry.file, path, entry.relativePath)
|
||||||
|
: uploadDocument(entry.file, path);
|
||||||
|
promise
|
||||||
|
.then((result) => {
|
||||||
|
onUploaded(result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setUploadError(err instanceof Error ? err.message : '上传失败');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadFiles(files: FileList | File[]) {
|
function uploadFiles(files: FileList | File[]) {
|
||||||
Array.from(files).forEach((file) => {
|
Array.from(files).forEach((file) => uploadOne({ file }));
|
||||||
uploadDocument(file, path).then((result) => {
|
|
||||||
onUploaded(result);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) {
|
function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
@ -35,9 +56,24 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
|||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDrop(event: React.DragEvent<HTMLDivElement>) {
|
function handleFolderInputChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
if (event.target.files) {
|
||||||
|
Array.from(event.target.files).forEach((file) =>
|
||||||
|
uploadOne({ file, relativePath: file.webkitRelativePath || file.name }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(event: React.DragEvent<HTMLDivElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
|
const items = event.dataTransfer.items;
|
||||||
|
if (items && items.length > 0 && typeof items[0]?.webkitGetAsEntry === 'function') {
|
||||||
|
const entries = await readFileEntries(items);
|
||||||
|
entries.forEach(uploadOne);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.dataTransfer.files) {
|
if (event.dataTransfer.files) {
|
||||||
uploadFiles(event.dataTransfer.files);
|
uploadFiles(event.dataTransfer.files);
|
||||||
}
|
}
|
||||||
@ -54,10 +90,27 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button onClick={openModal}>上传文档</button>
|
<button className="upload-btn" onClick={openModal}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 3v12" />
|
||||||
|
<path d="M6 9l6-6 6 6" />
|
||||||
|
<path d="M4 21h16" />
|
||||||
|
</svg>
|
||||||
|
上传文档
|
||||||
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="modal-backdrop show" data-testid="upload-modal">
|
<div className="modal-backdrop show" data-testid="upload-modal">
|
||||||
<div className="modal">
|
<div className="modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>上传文档</h3>
|
||||||
|
<button className="modal-close" onClick={closeModal} aria-label="关闭">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
<div
|
<div
|
||||||
className={isDragOver ? 'dropzone dragover' : 'dropzone'}
|
className={isDragOver ? 'dropzone dragover' : 'dropzone'}
|
||||||
data-testid="dropzone"
|
data-testid="dropzone"
|
||||||
@ -66,8 +119,19 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
|||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
>
|
>
|
||||||
拖拽文件到此处,或点击选择
|
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||||
|
<path d="M12 3v12" />
|
||||||
|
<path d="M6 9l6-6 6 6" />
|
||||||
|
<path d="M4 21h16" />
|
||||||
|
</svg>
|
||||||
|
拖拽文件或文件夹到此处,或点击选择
|
||||||
|
<div className="hint">支持 .md .html 及常见图片格式</div>
|
||||||
</div>
|
</div>
|
||||||
|
{uploadError && (
|
||||||
|
<div className="upload-error" data-testid="upload-error">
|
||||||
|
{uploadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@ -76,7 +140,22 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
/>
|
/>
|
||||||
<button onClick={closeModal}>取消</button>
|
<input
|
||||||
|
ref={setFolderInputRef}
|
||||||
|
type="file"
|
||||||
|
data-testid="upload-folder-input"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFolderInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn-secondary" onClick={() => folderInputRef.current?.click()}>
|
||||||
|
选择文件夹
|
||||||
|
</button>
|
||||||
|
<button className="btn-secondary" onClick={closeModal}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
52
frontend/src/lib/fileSystemEntry.ts
Normal file
52
frontend/src/lib/fileSystemEntry.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
export interface FileWithRelativePath {
|
||||||
|
file: File;
|
||||||
|
relativePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
reader.readEntries(resolve, reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectAllEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
|
||||||
|
const all: FileSystemEntry[] = [];
|
||||||
|
let batch = await readEntries(reader);
|
||||||
|
while (batch.length > 0) {
|
||||||
|
all.push(...batch);
|
||||||
|
batch = await readEntries(reader);
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryToFile(entry: FileSystemFileEntry): Promise<File> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
entry.file(resolve, reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walkEntry(entry: FileSystemEntry, results: FileWithRelativePath[]): Promise<void> {
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file = await entryToFile(entry as FileSystemFileEntry);
|
||||||
|
results.push({ file, relativePath: entry.fullPath.replace(/^\//, '') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||||
|
const children = await collectAllEntries(reader);
|
||||||
|
for (const child of children) {
|
||||||
|
await walkEntry(child, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readFileEntries(items: DataTransferItemList): Promise<FileWithRelativePath[]> {
|
||||||
|
const results: FileWithRelativePath[] = [];
|
||||||
|
const entries = Array.from(items)
|
||||||
|
.map((item) => item.webkitGetAsEntry())
|
||||||
|
.filter((entry): entry is FileSystemEntry => entry !== null);
|
||||||
|
for (const entry of entries) {
|
||||||
|
await walkEntry(entry, results);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
618
frontend/src/styles.css
Normal file
618
frontend/src/styles.css
Normal file
@ -0,0 +1,618 @@
|
|||||||
|
:root {
|
||||||
|
--paper: #faf9f6;
|
||||||
|
--paper-alt: #f1efe6;
|
||||||
|
--ink: #1b1b18;
|
||||||
|
--ash: #86847c;
|
||||||
|
--ash-faint: #b3b0a5;
|
||||||
|
--rule: #e4e1d8;
|
||||||
|
--pine: #2f5d50;
|
||||||
|
--pine-deep: #234840;
|
||||||
|
--pine-soft: #e7efec;
|
||||||
|
--amber: #e8a33d;
|
||||||
|
--amber-soft: #fbead0;
|
||||||
|
--radius: 6px;
|
||||||
|
--shadow: 0 12px 36px rgba(27, 27, 24, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', -apple-system, 'PingFang SC', sans-serif;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.serif {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: 'IBM Plex Mono', Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-tag {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--pine);
|
||||||
|
background: var(--pine-soft);
|
||||||
|
border: 1px solid rgba(47, 93, 80, 0.18);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Top bar ---------- */
|
||||||
|
.topbar {
|
||||||
|
height: 58px;
|
||||||
|
background: var(--paper);
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 22px;
|
||||||
|
gap: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 19px;
|
||||||
|
color: var(--ink);
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark .subtitle {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--ash);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 540px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--paper-alt);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 9px 14px 9px 38px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input::placeholder {
|
||||||
|
color: var(--ash-faint);
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input:focus {
|
||||||
|
border-color: var(--pine);
|
||||||
|
background: var(--paper);
|
||||||
|
box-shadow: 0 0 0 3px var(--pine-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box svg {
|
||||||
|
position: absolute;
|
||||||
|
left: 13px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--ash);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--pine);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 9px 16px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn:hover {
|
||||||
|
background: var(--pine-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Body layout ---------- */
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Sidebar ---------- */
|
||||||
|
.sidebar {
|
||||||
|
width: 272px;
|
||||||
|
background: var(--paper-alt);
|
||||||
|
border-right: 1px solid var(--rule);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 4px 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title .count {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
color: var(--ash);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ink);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 5px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item:hover {
|
||||||
|
background: rgba(27, 27, 24, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item.file-row.active {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 0 1px var(--rule);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item.folder {
|
||||||
|
color: var(--ash);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 11.5px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-children {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
display: inline-flex;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
color: var(--ash-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row .filename {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
color: var(--ash);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Main ---------- */
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--ash-faint);
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-header {
|
||||||
|
height: 54px;
|
||||||
|
background: var(--paper);
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-path {
|
||||||
|
color: var(--ash);
|
||||||
|
font-size: 12.5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-path .sep {
|
||||||
|
color: var(--ash-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-path .current {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ash-faint);
|
||||||
|
margin-right: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status .dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ash-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status.dirty {
|
||||||
|
color: #a9762a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status.dirty .dot {
|
||||||
|
background: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status.saved {
|
||||||
|
color: var(--pine);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-status.saved .dot {
|
||||||
|
background: var(--pine);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-error {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #a9401f;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-error button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
background: var(--pine);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 7px 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:hover {
|
||||||
|
background: var(--pine-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:disabled {
|
||||||
|
background: var(--ash-faint);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ash);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 7px 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--paper-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-area > section {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Search overlay ---------- */
|
||||||
|
.search-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
z-index: 10;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-header {
|
||||||
|
padding: 24px 30px 12px;
|
||||||
|
color: var(--ash);
|
||||||
|
font-size: 12.5px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-header .big-count {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
padding: 16px 30px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover {
|
||||||
|
background: var(--paper-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-path {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ash);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-snippet {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ash);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-snippet em {
|
||||||
|
background: var(--amber-soft);
|
||||||
|
color: #8a5a12;
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
padding: 60px 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ash-faint);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results .serif {
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--ash);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Status bar ---------- */
|
||||||
|
.statusbar {
|
||||||
|
height: 28px;
|
||||||
|
background: var(--paper-alt);
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
color: var(--ash);
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 10.5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 18px;
|
||||||
|
gap: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Upload modal ---------- */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(27, 27, 24, 0.32);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 460px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--ash);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone {
|
||||||
|
border: 1.5px dashed var(--rule);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 36px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ash);
|
||||||
|
font-size: 12.5px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone.dragover {
|
||||||
|
border-color: var(--pine);
|
||||||
|
background: var(--pine-soft);
|
||||||
|
color: var(--pine-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone svg {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
color: var(--ash-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone.dragover svg {
|
||||||
|
color: var(--pine);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone .hint {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--ash-faint);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-error {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fdece6;
|
||||||
|
color: #a9401f;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 14px 22px;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import { test, expect, beforeEach, vi } from 'vitest';
|
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import App from '../src/App';
|
import App from '../src/App';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { waitFor } from '@testing-library/react';
|
import { waitFor } from '@testing-library/react';
|
||||||
import { listDocuments, getDocument, search } from '../src/api/client';
|
import { listDocuments, getDocument, search, uploadDocument } from '../src/api/client';
|
||||||
|
|
||||||
vi.mock('../src/api/client');
|
vi.mock('../src/api/client');
|
||||||
|
|
||||||
@ -21,6 +21,7 @@ beforeEach(() => {
|
|||||||
vi.mocked(listDocuments).mockReset();
|
vi.mocked(listDocuments).mockReset();
|
||||||
vi.mocked(getDocument).mockReset();
|
vi.mocked(getDocument).mockReset();
|
||||||
vi.mocked(search).mockReset();
|
vi.mocked(search).mockReset();
|
||||||
|
vi.mocked(uploadDocument).mockReset();
|
||||||
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||||
vi.mocked(getDocument).mockResolvedValue({
|
vi.mocked(getDocument).mockResolvedValue({
|
||||||
key: '',
|
key: '',
|
||||||
@ -30,6 +31,10 @@ beforeEach(() => {
|
|||||||
vi.mocked(search).mockResolvedValue({ results: [] });
|
vi.mocked(search).mockResolvedValue({ results: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
test('renders the DocHub heading', () => {
|
test('renders the DocHub heading', () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument();
|
||||||
@ -100,3 +105,127 @@ test('searching shows the results overlay, and clicking a result opens the docum
|
|||||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||||
expect(textarea).toHaveValue('# 架构设计');
|
expect(textarea).toHaveValue('# 架构设计');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('jumping to a search result clears the search input so it does not show stale text', async () => {
|
||||||
|
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||||
|
vi.mocked(search).mockResolvedValue({
|
||||||
|
results: [{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' }],
|
||||||
|
});
|
||||||
|
vi.mocked(getDocument).mockResolvedValue({
|
||||||
|
key: '产品文档/架构设计.md',
|
||||||
|
content: '# 架构设计',
|
||||||
|
contentType: 'text/markdown',
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox', { name: '搜索文档' }) as HTMLInputElement;
|
||||||
|
await user.type(searchInput, '检索');
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.getByTestId('search-result-产品文档/架构设计.md')).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('search-result-产品文档/架构设计.md'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(searchInput).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('typing a query does not show a false "no results" message before the debounced search resolves', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||||
|
vi.mocked(search).mockResolvedValue({ results: [] });
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox', { name: '搜索文档' });
|
||||||
|
fireEvent.change(searchInput, { target: { value: '检索' } });
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('search-no-results')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('search-no-results')).toBeInTheDocument();
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching files while the current document is dirty prompts for confirmation', async () => {
|
||||||
|
vi.mocked(listDocuments).mockResolvedValue({
|
||||||
|
folders: [],
|
||||||
|
files: [
|
||||||
|
{ key: '文档A.md', name: '文档A.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
||||||
|
{ key: '文档B.md', name: '文档B.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(getDocument).mockImplementation((key: string) =>
|
||||||
|
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown' })
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText('文档A.md'));
|
||||||
|
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||||
|
await user.type(textarea, '修改内容');
|
||||||
|
|
||||||
|
await user.click(screen.getByText('文档B.md'));
|
||||||
|
|
||||||
|
expect(confirmSpy).toHaveBeenCalled();
|
||||||
|
expect(await screen.findByTestId('monaco-editor-mock')).toHaveValue('内容-文档A.md修改内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirming the discard prompt proceeds to switch files', async () => {
|
||||||
|
vi.mocked(listDocuments).mockResolvedValue({
|
||||||
|
folders: [],
|
||||||
|
files: [
|
||||||
|
{ key: '文档A.md', name: '文档A.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
||||||
|
{ key: '文档B.md', name: '文档B.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(getDocument).mockImplementation((key: string) =>
|
||||||
|
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown' })
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText('文档A.md'));
|
||||||
|
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||||
|
await user.type(textarea, '修改内容');
|
||||||
|
|
||||||
|
await user.click(screen.getByText('文档B.md'));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
expect(await screen.findByTestId('monaco-editor-mock')).toHaveValue('内容-文档B.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a successful upload refreshes the file tree', async () => {
|
||||||
|
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||||
|
vi.mocked(uploadDocument).mockResolvedValue({ key: '新文档.md', name: '新文档.md', size: 5 });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(listDocuments).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '上传文档' }));
|
||||||
|
const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' });
|
||||||
|
const input = screen.getByTestId('upload-file-input');
|
||||||
|
await user.upload(input, file);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(listDocuments).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
import { test, expect, vi, beforeEach } from 'vitest';
|
||||||
import { listDocuments, getDocument, saveDocument, deleteDocument, uploadDocument, search } from '../../src/api/client';
|
import { listDocuments, getDocument, saveDocument, deleteDocument, uploadDocument, search } from '../../src/api/client';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -10,33 +10,33 @@ test('listDocuments calls GET /api/documents with the path query param and retur
|
|||||||
folders: ['产品文档'],
|
folders: ['产品文档'],
|
||||||
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 512, modifiedAt: '2026-08-01T10:00:00Z' }],
|
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 512, modifiedAt: '2026-08-01T10:00:00Z' }],
|
||||||
};
|
};
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockResponse),
|
json: () => Promise.resolve(mockResponse),
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
const result = await listDocuments('产品文档');
|
const result = await listDocuments('产品文档');
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`);
|
expect(globalThis.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`);
|
||||||
expect(result).toEqual(mockResponse);
|
expect(result).toEqual(mockResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('listDocuments throws when the response is not ok', async () => {
|
test('listDocuments throws when the response is not ok', async () => {
|
||||||
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch;
|
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch;
|
||||||
|
|
||||||
await expect(listDocuments('')).rejects.toThrow('Failed to list documents: 500');
|
await expect(listDocuments('')).rejects.toThrow('Failed to list documents: 500');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getDocument calls GET /api/documents/{key} preserving slashes in the key', async () => {
|
test('getDocument calls GET /api/documents/{key} preserving slashes in the key', async () => {
|
||||||
const mockResponse = { key: '产品文档/架构设计.md', content: '# 架构设计', contentType: 'text/markdown' };
|
const mockResponse = { key: '产品文档/架构设计.md', content: '# 架构设计', contentType: 'text/markdown' };
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockResponse),
|
json: () => Promise.resolve(mockResponse),
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
const result = await getDocument('产品文档/架构设计.md');
|
const result = await getDocument('产品文档/架构设计.md');
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`
|
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`
|
||||||
);
|
);
|
||||||
expect(result).toEqual(mockResponse);
|
expect(result).toEqual(mockResponse);
|
||||||
@ -44,14 +44,14 @@ test('getDocument calls GET /api/documents/{key} preserving slashes in the key',
|
|||||||
|
|
||||||
test('saveDocument calls PUT /api/documents/{key} with content and contentType', async () => {
|
test('saveDocument calls PUT /api/documents/{key} with content and contentType', async () => {
|
||||||
const mockResponse = { key: '产品文档/架构设计.md', updatedAt: '2026-08-01T10:05:00Z' };
|
const mockResponse = { key: '产品文档/架构设计.md', updatedAt: '2026-08-01T10:05:00Z' };
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockResponse),
|
json: () => Promise.resolve(mockResponse),
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown');
|
const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown');
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
|
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@ -63,11 +63,11 @@ test('saveDocument calls PUT /api/documents/{key} with content and contentType',
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('deleteDocument calls DELETE /api/documents/{key} and resolves with no value', async () => {
|
test('deleteDocument calls DELETE /api/documents/{key} and resolves with no value', async () => {
|
||||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch;
|
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch;
|
||||||
|
|
||||||
await expect(deleteDocument('产品文档/架构设计.md')).resolves.toBeUndefined();
|
await expect(deleteDocument('产品文档/架构设计.md')).resolves.toBeUndefined();
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
|
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
|
||||||
{ method: 'DELETE' }
|
{ method: 'DELETE' }
|
||||||
);
|
);
|
||||||
@ -75,7 +75,7 @@ test('deleteDocument calls DELETE /api/documents/{key} and resolves with no valu
|
|||||||
|
|
||||||
test('uploadDocument posts a multipart form with file and path fields', async () => {
|
test('uploadDocument posts a multipart form with file and path fields', async () => {
|
||||||
const mockResponse = { key: '产品文档/新文档.md', name: '新文档.md', size: 42 };
|
const mockResponse = { key: '产品文档/新文档.md', name: '新文档.md', size: 42 };
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockResponse),
|
json: () => Promise.resolve(mockResponse),
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
@ -83,29 +83,45 @@ test('uploadDocument posts a multipart form with file and path fields', async ()
|
|||||||
|
|
||||||
const result = await uploadDocument(file, '产品文档');
|
const result = await uploadDocument(file, '产品文档');
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||||
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
const [url, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
expect(url).toBe('/api/upload');
|
expect(url).toBe('/api/upload');
|
||||||
expect(init.method).toBe('POST');
|
expect(init.method).toBe('POST');
|
||||||
const body = init.body as FormData;
|
const body = init.body as FormData;
|
||||||
expect(body.get('file')).toBe(file);
|
expect(body.get('file')).toBe(file);
|
||||||
expect(body.get('path')).toBe('产品文档');
|
expect(body.get('path')).toBe('产品文档');
|
||||||
|
expect(body.get('relative_path')).toBeNull();
|
||||||
expect(result).toEqual(mockResponse);
|
expect(result).toEqual(mockResponse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('uploadDocument includes relative_path when uploading a file from a folder', async () => {
|
||||||
|
const mockResponse = { key: '产品文档/子目录/新文档.md', name: '新文档.md', size: 42 };
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' });
|
||||||
|
|
||||||
|
await uploadDocument(file, '产品文档', '子目录/新文档.md');
|
||||||
|
|
||||||
|
const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
const body = init.body as FormData;
|
||||||
|
expect(body.get('relative_path')).toBe('子目录/新文档.md');
|
||||||
|
});
|
||||||
|
|
||||||
test('search calls GET /api/search with the q query param and returns parsed JSON', async () => {
|
test('search calls GET /api/search with the q query param and returns parsed JSON', async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
results: [
|
results: [
|
||||||
{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' },
|
{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockResponse),
|
json: () => Promise.resolve(mockResponse),
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
const result = await search('检索');
|
const result = await search('检索');
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`);
|
expect(globalThis.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`);
|
||||||
expect(result).toEqual(mockResponse);
|
expect(result).toEqual(mockResponse);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { vi, test, expect, beforeEach } from 'vitest';
|
import { vi, test, expect, beforeEach } from 'vitest';
|
||||||
import DocumentEditor from '../../src/components/DocumentEditor';
|
import DocumentEditor from '../../src/components/DocumentEditor';
|
||||||
import { getDocument, saveDocument } from '../../src/api/client';
|
import { deleteDocument, getDocument, saveDocument } from '../../src/api/client';
|
||||||
|
|
||||||
vi.mock('../../src/api/client');
|
vi.mock('../../src/api/client');
|
||||||
|
|
||||||
@ -18,6 +18,7 @@ vi.mock('@monaco-editor/react', () => ({
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getDocument).mockReset();
|
vi.mocked(getDocument).mockReset();
|
||||||
vi.mocked(saveDocument).mockReset();
|
vi.mocked(saveDocument).mockReset();
|
||||||
|
vi.mocked(deleteDocument).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows a loading state, then loads and displays document content', async () => {
|
test('shows a loading state, then loads and displays document content', async () => {
|
||||||
@ -27,7 +28,7 @@ test('shows a loading state, then loads and displays document content', async ()
|
|||||||
contentType: 'text/markdown',
|
contentType: 'text/markdown',
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||||
|
|
||||||
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
|
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
|
||||||
|
|
||||||
@ -52,7 +53,7 @@ test('typing marks the document dirty and saving clears the dirty state', async
|
|||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||||
|
|
||||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||||
await user.clear(textarea);
|
await user.clear(textarea);
|
||||||
@ -70,3 +71,133 @@ test('typing marks the document dirty and saving clears the dirty state', async
|
|||||||
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
|
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('save failure keeps the editor and unsaved content visible, shows an error, and allows retry', async () => {
|
||||||
|
vi.mocked(getDocument).mockResolvedValue({
|
||||||
|
key: '产品文档/架构设计.md',
|
||||||
|
content: '# 架构设计',
|
||||||
|
contentType: 'text/markdown',
|
||||||
|
});
|
||||||
|
vi.mocked(saveDocument).mockRejectedValueOnce(new Error('network error'));
|
||||||
|
vi.mocked(saveDocument).mockResolvedValueOnce({
|
||||||
|
key: '产品文档/架构设计.md',
|
||||||
|
updatedAt: '2026-08-01T10:05:00Z',
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
|
||||||
|
|
||||||
|
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||||
|
await user.clear(textarea);
|
||||||
|
await user.type(textarea, '# 未保存的修改');
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: '保存' });
|
||||||
|
await user.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('save-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('document-editor')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('monaco-editor-mock')).toHaveValue('# 未保存的修改');
|
||||||
|
expect(screen.getByTestId('save-status')).toHaveTextContent('未保存');
|
||||||
|
|
||||||
|
await user.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('save-error')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls onDirtyChange when dirty state changes', async () => {
|
||||||
|
vi.mocked(getDocument).mockResolvedValue({
|
||||||
|
key: '产品文档/架构设计.md',
|
||||||
|
content: '# 架构设计',
|
||||||
|
contentType: 'text/markdown',
|
||||||
|
});
|
||||||
|
const onDirtyChange = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} onDeleted={vi.fn()} />);
|
||||||
|
|
||||||
|
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.type(textarea, '追加内容');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking delete confirms, calls deleteDocument, and calls onDeleted on success', async () => {
|
||||||
|
vi.mocked(getDocument).mockResolvedValue({
|
||||||
|
key: '产品文档/架构设计.md',
|
||||||
|
content: '# 架构设计',
|
||||||
|
contentType: 'text/markdown',
|
||||||
|
});
|
||||||
|
vi.mocked(deleteDocument).mockResolvedValue(undefined);
|
||||||
|
const onDeleted = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||||
|
|
||||||
|
await screen.findByTestId('monaco-editor-mock');
|
||||||
|
const deleteButton = screen.getByRole('button', { name: '删除' });
|
||||||
|
await user.click(deleteButton);
|
||||||
|
|
||||||
|
expect(confirmSpy).toHaveBeenCalledWith('确定要删除这篇文档吗?此操作无法撤销。');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(deleteDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onDeleted).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking delete does nothing if the confirmation is declined', async () => {
|
||||||
|
vi.mocked(getDocument).mockResolvedValue({
|
||||||
|
key: '产品文档/架构设计.md',
|
||||||
|
content: '# 架构设计',
|
||||||
|
contentType: 'text/markdown',
|
||||||
|
});
|
||||||
|
const onDeleted = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
|
||||||
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||||
|
|
||||||
|
await screen.findByTestId('monaco-editor-mock');
|
||||||
|
const deleteButton = screen.getByRole('button', { name: '删除' });
|
||||||
|
await user.click(deleteButton);
|
||||||
|
|
||||||
|
expect(deleteDocument).not.toHaveBeenCalled();
|
||||||
|
expect(onDeleted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows an inline error and does not call onDeleted when delete fails', async () => {
|
||||||
|
vi.mocked(getDocument).mockResolvedValue({
|
||||||
|
key: '产品文档/架构设计.md',
|
||||||
|
content: '# 架构设计',
|
||||||
|
contentType: 'text/markdown',
|
||||||
|
});
|
||||||
|
vi.mocked(deleteDocument).mockRejectedValue(new Error('network error'));
|
||||||
|
const onDeleted = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
|
||||||
|
|
||||||
|
await screen.findByTestId('monaco-editor-mock');
|
||||||
|
const deleteButton = screen.getByRole('button', { name: '删除' });
|
||||||
|
await user.click(deleteButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('delete-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(onDeleted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { vi, test, expect, beforeEach } from 'vitest';
|
import { vi, test, expect, beforeEach } from 'vitest';
|
||||||
import FileExplorer from '../../src/components/FileExplorer';
|
import FileExplorer from '../../src/components/FileExplorer';
|
||||||
import { listDocuments } from '../../src/api/client';
|
import { listDocuments } from '../../src/api/client';
|
||||||
@ -67,3 +67,19 @@ test('shows an error message when listDocuments rejects', async () => {
|
|||||||
|
|
||||||
expect(await screen.findByTestId('file-explorer-error')).toBeInTheDocument();
|
expect(await screen.findByTestId('file-explorer-error')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('changing refreshKey re-fetches the root listing', async () => {
|
||||||
|
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||||
|
|
||||||
|
const { rerender } = render(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} refreshKey={0} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(listDocuments).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} refreshKey={1} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(listDocuments).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,7 +1,19 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
|
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import SearchBar from '../../src/components/SearchBar';
|
import SearchBar from '../../src/components/SearchBar';
|
||||||
import { search } from '../../src/api/client';
|
import { search, type SearchResult } from '../../src/api/client';
|
||||||
|
|
||||||
|
function ControlledSearchBar({
|
||||||
|
onResults,
|
||||||
|
debounceMs,
|
||||||
|
}: {
|
||||||
|
onResults: (results: SearchResult[]) => void;
|
||||||
|
debounceMs?: number;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
return <SearchBar value={value} onQueryChange={setValue} onResults={onResults} debounceMs={debounceMs} />;
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('../../src/api/client');
|
vi.mock('../../src/api/client');
|
||||||
|
|
||||||
@ -19,7 +31,7 @@ test('debounces input for 300ms before calling search', async () => {
|
|||||||
const onQueryChange = vi.fn();
|
const onQueryChange = vi.fn();
|
||||||
const onResults = vi.fn();
|
const onResults = vi.fn();
|
||||||
|
|
||||||
render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);
|
render(<SearchBar value="" onQueryChange={onQueryChange} onResults={onResults} />);
|
||||||
|
|
||||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||||
fireEvent.change(input, { target: { value: '检索' } });
|
fireEvent.change(input, { target: { value: '检索' } });
|
||||||
@ -34,10 +46,9 @@ test('debounces input for 300ms before calling search', async () => {
|
|||||||
|
|
||||||
test('clearing the input immediately reports empty results without calling search', async () => {
|
test('clearing the input immediately reports empty results without calling search', async () => {
|
||||||
vi.mocked(search).mockResolvedValue({ results: [] });
|
vi.mocked(search).mockResolvedValue({ results: [] });
|
||||||
const onQueryChange = vi.fn();
|
|
||||||
const onResults = vi.fn();
|
const onResults = vi.fn();
|
||||||
|
|
||||||
render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);
|
render(<ControlledSearchBar onResults={onResults} />);
|
||||||
|
|
||||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||||
fireEvent.change(input, { target: { value: 'x' } });
|
fireEvent.change(input, { target: { value: 'x' } });
|
||||||
@ -46,3 +57,17 @@ test('clearing the input immediately reports empty results without calling searc
|
|||||||
|
|
||||||
expect(onResults).toHaveBeenCalledWith([]);
|
expect(onResults).toHaveBeenCalledWith([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reflects the value prop, allowing a parent to control/reset the input', () => {
|
||||||
|
const onQueryChange = vi.fn();
|
||||||
|
const onResults = vi.fn();
|
||||||
|
|
||||||
|
const { rerender } = render(<SearchBar value="检索" onQueryChange={onQueryChange} onResults={onResults} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||||
|
expect(input).toHaveValue('检索');
|
||||||
|
|
||||||
|
rerender(<SearchBar value="" onQueryChange={onQueryChange} onResults={onResults} />);
|
||||||
|
|
||||||
|
expect(input).toHaveValue('');
|
||||||
|
});
|
||||||
|
|||||||
@ -39,6 +39,44 @@ test('selecting a file through the hidden input uploads it', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a failed upload shows an error message and keeps the modal open for retry', async () => {
|
||||||
|
vi.mocked(uploadDocument).mockRejectedValue(new Error('Failed to upload document: 400'));
|
||||||
|
const onUploaded = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: '上传文档' }));
|
||||||
|
|
||||||
|
const file = new File(['data'], '恶意程序.exe', { type: 'application/octet-stream' });
|
||||||
|
const input = screen.getByTestId('upload-file-input');
|
||||||
|
await user.upload(input, file);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('upload-error')).toHaveTextContent('Failed to upload document: 400');
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('upload-modal')).toBeInTheDocument();
|
||||||
|
expect(onUploaded).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting a folder through the folder input uploads each file with its relative path', async () => {
|
||||||
|
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/子目录/新文档.md', name: '新文档.md', size: 8 });
|
||||||
|
const onUploaded = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: '上传文档' }));
|
||||||
|
|
||||||
|
const file = new File(['content'], '新文档.md', { type: 'text/markdown' });
|
||||||
|
Object.defineProperty(file, 'webkitRelativePath', { value: '子目录/新文档.md', configurable: true });
|
||||||
|
const input = screen.getByTestId('upload-folder-input');
|
||||||
|
await user.upload(input, file);
|
||||||
|
|
||||||
|
expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档', '子目录/新文档.md');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onUploaded).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('dropping a file onto the dropzone uploads it', async () => {
|
test('dropping a file onto the dropzone uploads it', async () => {
|
||||||
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
|
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
|
||||||
const onUploaded = vi.fn();
|
const onUploaded = vi.fn();
|
||||||
@ -59,3 +97,46 @@ test('dropping a file onto the dropzone uploads it', async () => {
|
|||||||
expect(onUploaded).toHaveBeenCalledWith({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
|
expect(onUploaded).toHaveBeenCalledWith({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dropping a folder onto the dropzone walks its entries and uploads each file with its relative path', async () => {
|
||||||
|
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/子目录/文件.md', name: '文件.md', size: 5 });
|
||||||
|
const onUploaded = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const file = new File(['# hi'], '文件.md', { type: 'text/markdown' });
|
||||||
|
|
||||||
|
const fileEntry = {
|
||||||
|
isFile: true,
|
||||||
|
isDirectory: false,
|
||||||
|
fullPath: '/子目录/文件.md',
|
||||||
|
file: (success: (f: File) => void) => success(file),
|
||||||
|
};
|
||||||
|
let readCount = 0;
|
||||||
|
const dirReader = {
|
||||||
|
readEntries: (success: (entries: unknown[]) => void) => {
|
||||||
|
readCount += 1;
|
||||||
|
success(readCount === 1 ? [fileEntry] : []);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const dirEntry = {
|
||||||
|
isFile: false,
|
||||||
|
isDirectory: true,
|
||||||
|
fullPath: '/子目录',
|
||||||
|
createReader: () => dirReader,
|
||||||
|
};
|
||||||
|
const item = { webkitGetAsEntry: () => dirEntry };
|
||||||
|
|
||||||
|
render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: '上传文档' }));
|
||||||
|
const dropzone = screen.getByTestId('dropzone');
|
||||||
|
|
||||||
|
fireEvent.drop(dropzone, {
|
||||||
|
dataTransfer: { items: [item], files: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档', '子目录/文件.md');
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onUploaded).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user