diff --git a/backend/app/document_service.py b/backend/app/document_service.py
index 959dd96..64bac50 100644
--- a/backend/app/document_service.py
+++ b/backend/app/document_service.py
@@ -1,11 +1,16 @@
import base64
import logging
+import re
from datetime import datetime, timezone
from app.text_extract import html_to_text, markdown_to_text
logger = logging.getLogger(__name__)
+
+def _natural_sort_key(name: str) -> list:
+ return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", name)]
+
ALLOWED_UPLOAD_EXTENSIONS = {".md", ".html", ".htm", ".png", ".jpg", ".jpeg", ".gif", ".webp"}
MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024
@@ -76,6 +81,8 @@ class DocumentService:
"size": entry["size"],
"modifiedAt": entry["modifiedAt"],
})
+ folders.sort(key=_natural_sort_key)
+ files.sort(key=lambda f: _natural_sort_key(f["name"]))
return {"folders": folders, "files": files}
def get_document(self, key: str) -> dict:
@@ -135,3 +142,24 @@ class DocumentService:
self.save_document(key, data, content_type)
name = key.rsplit("/", 1)[-1]
return {"key": key, "name": name, "size": len(data)}
+
+ def reindex_all(self) -> dict:
+ self._search_client.delete_all_documents()
+ keys = self._file_client.walk_files()
+ indexed = 0
+ failed = 0
+ for key in keys:
+ content_type = _guess_content_type(key)
+ if content_type == "application/octet-stream":
+ continue
+ try:
+ data = self._file_client.get_object(key)
+ text = self._extract_text(data, content_type)
+ title = key.rsplit("/", 1)[-1]
+ path = key.rsplit("/", 1)[0] if "/" in key else ""
+ self._search_client.index_document(key, path, title, text)
+ indexed += 1
+ except Exception:
+ logger.exception("Failed to reindex %s", key)
+ failed += 1
+ return {"indexed": indexed, "failed": failed, "total": len(keys)}
diff --git a/backend/app/local_file_client.py b/backend/app/local_file_client.py
index b0dd976..649e798 100644
--- a/backend/app/local_file_client.py
+++ b/backend/app/local_file_client.py
@@ -74,3 +74,13 @@ class LocalFileClient:
parent = parent.parent
else:
break
+
+ def walk_files(self) -> list[str]:
+ """递归遍历所有文件,返回相对于 base_dir 的 key 列表(不含目录)"""
+ result = []
+ for root, _dirs, filenames in os.walk(self._base_dir):
+ for filename in filenames:
+ full_path = Path(root) / filename
+ rel_path = full_path.relative_to(self._base_dir)
+ result.append(str(rel_path).replace(os.sep, "/"))
+ return result
diff --git a/backend/app/routers/search.py b/backend/app/routers/search.py
index a2d3d2b..2c5d0ac 100644
--- a/backend/app/routers/search.py
+++ b/backend/app/routers/search.py
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends
-from app.dependencies import get_search_client
+from app.dependencies import get_document_service, get_search_client
+from app.document_service import DocumentService
from app.search_client import SearchClient
router = APIRouter()
@@ -9,3 +10,8 @@ router = APIRouter()
@router.get("/api/search")
def search(q: str = "", client: SearchClient = Depends(get_search_client)):
return {"results": client.search(q)}
+
+
+@router.post("/api/search/reindex")
+def reindex(service: DocumentService = Depends(get_document_service)):
+ return service.reindex_all()
diff --git a/backend/app/search_client.py b/backend/app/search_client.py
index bfa9650..5d561c2 100644
--- a/backend/app/search_client.py
+++ b/backend/app/search_client.py
@@ -24,6 +24,10 @@ class SearchClient:
index = self._client.index(self._index_name)
index.delete_document(_encode_key(key))
+ def delete_all_documents(self) -> None:
+ index = self._client.index(self._index_name)
+ index.delete_all_documents()
+
def search(self, query: str) -> list[dict]:
index = self._client.index(self._index_name)
response = index.search(query, {
diff --git a/backend/tests/test_document_service.py b/backend/tests/test_document_service.py
index 2d25338..4ad383e 100644
--- a/backend/tests/test_document_service.py
+++ b/backend/tests/test_document_service.py
@@ -35,6 +35,9 @@ class FakeFileClient:
del self.stored[key]
self.deleted_keys.append(key)
+ def walk_files(self):
+ return list(self.stored.keys())
+
class FakeSearchClient:
def __init__(self, fail_on_index=False, fail_on_delete=False):
@@ -42,6 +45,7 @@ class FakeSearchClient:
self.deleted = []
self.fail_on_index = fail_on_index
self.fail_on_delete = fail_on_delete
+ self.cleared = False
def index_document(self, key, path, title, content):
if self.fail_on_index:
@@ -53,6 +57,27 @@ class FakeSearchClient:
raise RuntimeError("meilisearch unavailable")
self.deleted.append(key)
+ def delete_all_documents(self):
+ self.cleared = True
+
+
+def test_list_documents_sorts_files_naturally_by_numeric_prefix():
+ file_client = FakeFileClient()
+ file_client.entries = [
+ {"key": "8.10 系统设计.md", "is_dir": False, "size": 1, "modifiedAt": None},
+ {"key": "8.1 GMP调度.md", "is_dir": False, "size": 1, "modifiedAt": None},
+ {"key": "8.2 Channel.md", "is_dir": False, "size": 1, "modifiedAt": None},
+ ]
+ service = DocumentService(file_client, FakeSearchClient())
+
+ result = service.list_documents("")
+
+ assert [f["name"] for f in result["files"]] == [
+ "8.1 GMP调度.md",
+ "8.2 Channel.md",
+ "8.10 系统设计.md",
+ ]
+
def test_list_documents_at_root_returns_folders_and_files():
file_client = FakeFileClient()
@@ -240,3 +265,28 @@ def test_upload_document_accepts_allowed_image_extension():
assert result == {"key": "产品文档/图片.png", "name": "图片.png", "size": len(data)}
assert file_client.stored["产品文档/图片.png"] == data
+
+
+def test_reindex_all_clears_index_then_reindexes_every_supported_file():
+ file_client = FakeFileClient()
+ file_client.stored["产品文档/架构设计.md"] = "# 架构设计".encode("utf-8")
+ file_client.stored["产品文档/图片.png"] = b"\x89PNG\r\n\x1a\n"
+ search_client = FakeSearchClient()
+ service = DocumentService(file_client, search_client)
+
+ result = service.reindex_all()
+
+ assert search_client.cleared
+ assert result == {"indexed": 1, "failed": 0, "total": 2}
+ assert search_client.indexed[0]["key"] == "产品文档/架构设计.md"
+
+
+def test_reindex_all_counts_failures_without_raising():
+ file_client = FakeFileClient()
+ file_client.stored["产品文档/架构设计.md"] = "# 架构设计".encode("utf-8")
+ search_client = FakeSearchClient(fail_on_index=True)
+ service = DocumentService(file_client, search_client)
+
+ result = service.reindex_all()
+
+ assert result == {"indexed": 0, "failed": 1, "total": 1}
diff --git a/backend/tests/test_local_file_client.py b/backend/tests/test_local_file_client.py
index aa1a59e..1204da5 100644
--- a/backend/tests/test_local_file_client.py
+++ b/backend/tests/test_local_file_client.py
@@ -52,3 +52,21 @@ def test_list_objects_returns_empty_list_for_missing_prefix(client):
def test_resolve_path_rejects_path_traversal(client):
with pytest.raises(ValueError):
client.get_object("../../etc/passwd")
+
+
+def test_walk_files_returns_all_nested_file_keys(client):
+ client.put_object("产品文档/架构设计.md", b"# hi", "text/markdown")
+ client.put_object("产品文档/子目录/详情.md", "# 详情".encode("utf-8"), "text/markdown")
+ client.put_object("团队规范/编码规范.md", b"# rules", "text/markdown")
+
+ result = client.walk_files()
+
+ assert sorted(result) == sorted([
+ "产品文档/架构设计.md",
+ "产品文档/子目录/详情.md",
+ "团队规范/编码规范.md",
+ ])
+
+
+def test_walk_files_returns_empty_list_when_no_files(client):
+ assert client.walk_files() == []
diff --git a/backend/tests/test_routers_search.py b/backend/tests/test_routers_search.py
index 818f9ff..98618b6 100644
--- a/backend/tests/test_routers_search.py
+++ b/backend/tests/test_routers_search.py
@@ -2,7 +2,7 @@ import pytest
from fastapi.testclient import TestClient
from app.main import app
-from app.dependencies import get_search_client
+from app.dependencies import get_document_service, get_search_client
class FakeSearchClient:
@@ -50,3 +50,29 @@ def test_search_returns_empty_results_for_no_match(client):
def test_search_passes_query_string_to_search_client(client):
client.get("/api/search", params={"q": "Garage"})
assert client.fake_client.queries == ["Garage"]
+
+
+class FakeDocumentService:
+ def __init__(self):
+ self.reindex_called = False
+
+ def reindex_all(self):
+ self.reindex_called = True
+ return {"indexed": 3, "failed": 0, "total": 3}
+
+
+@pytest.fixture
+def reindex_client():
+ fake_service = FakeDocumentService()
+ app.dependency_overrides[get_document_service] = lambda: fake_service
+ with TestClient(app) as test_client:
+ test_client.fake_service = fake_service
+ yield test_client
+ app.dependency_overrides.clear()
+
+
+def test_reindex_triggers_full_reindex_and_returns_summary(reindex_client):
+ response = reindex_client.post("/api/search/reindex")
+ assert response.status_code == 200
+ assert response.json() == {"indexed": 3, "failed": 0, "total": 3}
+ assert reindex_client.fake_service.reindex_called
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 08ab843..b1de9ce 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4,6 +4,7 @@ import DocumentEditor from './components/DocumentEditor';
import SearchBar from './components/SearchBar';
import SearchResults from './components/SearchResults';
import UploadButton from './components/UploadButton';
+import ReindexButton from './components/ReindexButton';
import type { SearchResult, UploadDocumentResponse } from './api/client';
export default function App() {
@@ -66,6 +67,7 @@ export default function App() {
DocHub 档案 · 检索