From 35b7decd49a4f2997db19ef7ece0c85b7d891ae8 Mon Sep 17 00:00:00 2001 From: kplam Date: Tue, 4 Aug 2026 12:58:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=B8=8E=E5=85=A5=E5=BA=93=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E9=87=8D=E8=AF=95/=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 概览与检索合并为单一概览页 - 类目/文档管理/入库整合为树形目录知识库页(Library),支持文档搜索 - 入库进度改为右侧 Drawer,支持任务详情、重试、删除 - 后端新增任务重试与删除接口 --- app/api/v1/document.py | 22 + app/core/ingest_tasks.py | 50 ++ frontend/src/api/documents.js | 18 + frontend/src/composables/useTasksDrawer.js | 21 + frontend/src/layouts/MainLayout.vue | 20 +- frontend/src/router/index.js | 34 +- frontend/src/utils/format.js | 14 + frontend/src/views/Categories.vue | 70 -- frontend/src/views/Documents.vue | 346 --------- frontend/src/views/Ingest.vue | 495 ------------- frontend/src/views/Library.vue | 815 +++++++++++++++++++++ frontend/src/views/Overview.vue | 301 ++++++-- frontend/src/views/Search.vue | 234 ------ frontend/src/views/Tasks.vue | 179 ----- frontend/src/views/TasksDrawer.vue | 394 ++++++++++ tests/test_ingest_task_api.py | 49 ++ 16 files changed, 1630 insertions(+), 1432 deletions(-) create mode 100644 frontend/src/composables/useTasksDrawer.js delete mode 100644 frontend/src/views/Categories.vue delete mode 100644 frontend/src/views/Documents.vue delete mode 100644 frontend/src/views/Ingest.vue create mode 100644 frontend/src/views/Library.vue delete mode 100644 frontend/src/views/Search.vue delete mode 100644 frontend/src/views/Tasks.vue create mode 100644 frontend/src/views/TasksDrawer.vue diff --git a/app/api/v1/document.py b/app/api/v1/document.py index 6abb00c..a03ada8 100644 --- a/app/api/v1/document.py +++ b/app/api/v1/document.py @@ -335,6 +335,28 @@ async def get_ingest_task(task_id: str) -> dict[str, Any]: return ok(task) +@router.post("/documents/tasks/{task_id}/retry") +async def retry_ingest_task( + task_id: str, user: UserRecord = Depends(get_current_user) +) -> JSONResponse: + """重试入库任务:用原 source 重新提交一个新任务,返回新 task_id""" + new_task_id = await _get_task_manager().retry(task_id) + if new_task_id is None: + raise ApiError(1004, "任务不存在或缺少重试所需的源信息") + return JSONResponse( + status_code=202, content=ok({"task_id": new_task_id, "status": "pending"}) + ) + + +@router.delete("/documents/tasks/{task_id}") +async def delete_ingest_task( + task_id: str, user: UserRecord = Depends(get_current_user) +) -> dict[str, Any]: + """删除入库任务(内存注册表 + Redis 镜像),幂等""" + deleted = await _get_task_manager().delete(task_id) + return ok({"task_id": task_id, "deleted": deleted}) + + @router.get("/documents") async def list_documents( limit: int = Query(default=20, ge=1, le=100), diff --git a/app/core/ingest_tasks.py b/app/core/ingest_tasks.py index a94b887..735da30 100644 --- a/app/core/ingest_tasks.py +++ b/app/core/ingest_tasks.py @@ -80,6 +80,7 @@ class IngestTaskManager: task_id = uuid.uuid4().hex now = _utc_now_iso() filename = self._extract_filename(doc) + source = self._extract_source(doc) dedup = get_dedup_strategy(self._redis) # 1. 去重命中:直接置 done,复用旧结果,不调 _run @@ -95,6 +96,7 @@ class IngestTaskManager: "result": result_dict, "error": None, "filename": filename, + "source": source, } self._schedule_mirror(task_id) logger.info( @@ -113,6 +115,7 @@ class IngestTaskManager: "result": None, "error": None, "filename": filename, + "source": source, } self._schedule_mirror(task_id) background = asyncio.create_task(self._run(task_id, doc, dedup)) @@ -190,15 +193,62 @@ class IngestTaskManager: """从文档输入提取展示用文件名:优先 metadata.original_filename,其次 title""" return doc.metadata.get("original_filename") or doc.title or None + @staticmethod + def _extract_source(doc: DocumentInput) -> dict[str, Any]: + """从文档输入提取重试所需的源信息(供 retry 复用,含文件路径/大小)""" + return { + "text": doc.text, + "title": doc.title, + "source": doc.source, + "metadata": dict(doc.metadata), + } + + async def retry(self, task_id: str) -> str | None: + """重试失败/已完成的入库任务:用原 source 重新提交一个新任务 + + 返回新 task_id;任务不存在或缺少 source 时返回 None。 + """ + record = await self.get(task_id) + if record is None: + return None + source = record.get("source") + if not isinstance(source, dict) or not source.get("text"): + return None + doc = DocumentInput( + text=source["text"], + title=source.get("title") or "", + source=source.get("source") or "", + metadata=source.get("metadata") or {}, + ) + return await self.submit(doc) + + async def delete(self, task_id: str) -> bool: + """删除入库任务(内存注册表 + Redis 镜像),不存在返回 False""" + existed = self._tasks.pop(task_id, None) is not None + if self._redis is not None: + try: + get_client = getattr(self._redis, "_get_client", None) + if callable(get_client): + await get_client().delete(f"{REDIS_KEY_PREFIX}{task_id}") + existed = True + except Exception: + logger.warning("删除 Redis 任务镜像失败", task_id=task_id, exc_info=True) + return existed + @staticmethod def _to_list_item(record: dict[str, Any]) -> dict[str, Any]: """从完整任务记录提取列表展示字段""" result = record.get("result") doc_id = result.get("document_id") if isinstance(result, dict) else None + source = record.get("source") or {} + metadata = source.get("metadata") or {} return { "task_id": record.get("task_id"), "status": record.get("status"), "filename": record.get("filename"), + "title": source.get("title"), + "source": source.get("source"), + "size_bytes": metadata.get("original_size_bytes"), "created_at": record.get("created_at"), "updated_at": record.get("updated_at"), "doc_id": doc_id, diff --git a/frontend/src/api/documents.js b/frontend/src/api/documents.js index 047fe92..c38b288 100644 --- a/frontend/src/api/documents.js +++ b/frontend/src/api/documents.js @@ -83,6 +83,24 @@ export function taskList(limit = 20) { return http.get('/api/v1/documents/tasks', { params: { limit } }) } +/** + * 重试入库任务(用原 source 重新提交新任务) + * @param {string} taskId + * @returns {Promise<{task_id:string, status:string}>} + */ +export function retryTask(taskId) { + return http.post(`/api/v1/documents/tasks/${encodeURIComponent(taskId)}/retry`) +} + +/** + * 删除入库任务 + * @param {string} taskId + * @returns {Promise<{task_id:string, deleted:boolean}>} + */ +export function deleteTask(taskId) { + return http.delete(`/api/v1/documents/tasks/${encodeURIComponent(taskId)}`) +} + /** * 查询入库任务状态 * @param {string} taskId diff --git a/frontend/src/composables/useTasksDrawer.js b/frontend/src/composables/useTasksDrawer.js new file mode 100644 index 0000000..0aa4b4a --- /dev/null +++ b/frontend/src/composables/useTasksDrawer.js @@ -0,0 +1,21 @@ +import { reactive } from 'vue' + +/** + * 入库进度右侧 Drawer 的全局共享开关状态 + * + * 该 Drawer 渲染在 MainLayout 中,但可由任意子页面(如 Library)触发打开, + * 因此通过单例 reactive 状态 + open()/close() 方法解耦,避免多层 prop 传递。 + */ +const state = reactive({ open: false }) + +export function useTasksDrawer() { + return { + state, + open() { + state.open = true + }, + close() { + state.open = false + } + } +} \ No newline at end of file diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index b538556..d6c2947 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -5,9 +5,6 @@ import { storeToRefs } from 'pinia' import { Modal, message } from 'ant-design-vue' import { DashboardOutlined, - FileTextOutlined, - UploadOutlined, - SearchOutlined, AppstoreOutlined, SettingOutlined, MenuFoldOutlined, @@ -21,11 +18,14 @@ import { import { useAuthStore } from '@/stores/useAuthStore' import { changeMyPassword } from '@/api/auth' import { callApi } from '@/api/client' +import TasksDrawer from '@/views/TasksDrawer.vue' +import { useTasksDrawer } from '@/composables/useTasksDrawer' const route = useRoute() const router = useRouter() const authStore = useAuthStore() const { user, displayName } = storeToRefs(authStore) +const tasksDrawer = useTasksDrawer() const collapsed = ref(false) @@ -37,12 +37,9 @@ const openKeys = ref(['main']) const menuItems = computed(() => { const items = [ - { key: 'overview', icon: () => h(DashboardOutlined), label: '概览' }, - { key: 'documents', icon: () => h(FileTextOutlined), label: '文档管理' }, - { key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' }, + { key: 'overview', icon: () => h(DashboardOutlined), label: '概览与检索' }, + { key: 'library', icon: () => h(AppstoreOutlined), label: '知识库' }, { key: 'tasks', icon: () => h(ClockCircleOutlined), label: '入库进度' }, - { key: 'search', icon: () => h(SearchOutlined), label: '检索测试台' }, - { key: 'categories', icon: () => h(AppstoreOutlined), label: '类目列表' }, { key: 'settings', icon: () => h(SettingOutlined), label: '设置' }, { key: 'api-docs', icon: () => h(ApiOutlined), label: 'API 说明' } ] @@ -53,6 +50,10 @@ const menuItems = computed(() => { }) function handleMenuClick({ key }) { + if (key === 'tasks') { + tasksDrawer.open() + return + } if (key && key !== route.name) { router.push({ name: key }) } @@ -194,6 +195,9 @@ async function handleSubmitPassword() { + + + import('@/views/Overview.vue'), - meta: { title: '概览', requiresAuth: true } + meta: { title: '概览与检索', requiresAuth: true } }, { - path: 'documents', - name: 'documents', - component: () => import('@/views/Documents.vue'), - meta: { title: '文档管理', requiresAuth: true } - }, - { - path: 'ingest', - name: 'ingest', - component: () => import('@/views/Ingest.vue'), - meta: { title: '文档入库', requiresAuth: true } - }, - { - path: 'tasks', - name: 'tasks', - component: () => import('@/views/Tasks.vue'), - meta: { title: '入库进度', requiresAuth: true } - }, - { - path: 'search', - name: 'search', - component: () => import('@/views/Search.vue'), - meta: { title: '检索测试台', requiresAuth: true } - }, - { - path: 'categories', - name: 'categories', - component: () => import('@/views/Categories.vue'), - meta: { title: '类目列表', requiresAuth: true } + path: 'library', + name: 'library', + component: () => import('@/views/Library.vue'), + meta: { title: '知识库', requiresAuth: true } }, { path: 'settings', diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js index a8f8e3d..03ab8ce 100644 --- a/frontend/src/utils/format.js +++ b/frontend/src/utils/format.js @@ -10,6 +10,20 @@ export function truncate(text, maxLen = 80) { return s.length > maxLen ? `${s.slice(0, maxLen)}…` : s } +/** + * 格式化字节数为人类可读大小 + * @param {number|null|undefined} bytes + * @returns {string} + */ +export function formatSize(bytes) { + if (bytes == null || bytes === '') return '-' + const n = Number(bytes) + if (Number.isNaN(n)) return '-' + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` + return `${(n / 1024 / 1024).toFixed(1)} MB` +} + /** * 安全拼接类名 * @param {...(string | false | null | undefined)} args diff --git a/frontend/src/views/Categories.vue b/frontend/src/views/Categories.vue deleted file mode 100644 index ae1e23b..0000000 --- a/frontend/src/views/Categories.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - - - diff --git a/frontend/src/views/Documents.vue b/frontend/src/views/Documents.vue deleted file mode 100644 index 110c24c..0000000 --- a/frontend/src/views/Documents.vue +++ /dev/null @@ -1,346 +0,0 @@ - - - - - diff --git a/frontend/src/views/Ingest.vue b/frontend/src/views/Ingest.vue deleted file mode 100644 index 68abc92..0000000 --- a/frontend/src/views/Ingest.vue +++ /dev/null @@ -1,495 +0,0 @@ - - - - - diff --git a/frontend/src/views/Library.vue b/frontend/src/views/Library.vue new file mode 100644 index 0000000..6e1f396 --- /dev/null +++ b/frontend/src/views/Library.vue @@ -0,0 +1,815 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Overview.vue b/frontend/src/views/Overview.vue index 4b8adae..e71521e 100644 --- a/frontend/src/views/Overview.vue +++ b/frontend/src/views/Overview.vue @@ -1,5 +1,5 @@ - - - - diff --git a/frontend/src/views/Tasks.vue b/frontend/src/views/Tasks.vue deleted file mode 100644 index 72584a4..0000000 --- a/frontend/src/views/Tasks.vue +++ /dev/null @@ -1,179 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/views/TasksDrawer.vue b/frontend/src/views/TasksDrawer.vue new file mode 100644 index 0000000..259d3ef --- /dev/null +++ b/frontend/src/views/TasksDrawer.vue @@ -0,0 +1,394 @@ + + + + + \ No newline at end of file diff --git a/tests/test_ingest_task_api.py b/tests/test_ingest_task_api.py index c8a004b..9d3fc4c 100644 --- a/tests/test_ingest_task_api.py +++ b/tests/test_ingest_task_api.py @@ -19,6 +19,8 @@ class FakeManager: self.tasks = tasks or {} self.task_id = task_id self.submitted: list[DocumentInput] = [] + self.retry_result: str | None = None + self.delete_result: bool = False async def submit(self, doc: DocumentInput) -> str: self.submitted.append(doc) @@ -27,6 +29,12 @@ class FakeManager: async def get(self, task_id: str) -> dict[str, Any] | None: return self.tasks.get(task_id) + async def retry(self, task_id: str) -> str | None: + return self.retry_result + + async def delete(self, task_id: str) -> bool: + return self.delete_result + def _task(task_id: str, status: str, **extra: Any) -> dict[str, Any]: """构造一条任务记录(时间字段为固定 ISO8601 字符串)""" @@ -159,3 +167,44 @@ def test_post_empty_text_creates_no_task(client: TestClient, monkeypatch: pytest body = resp.json() assert body["code"] == 1001 assert manager.submitted == [] + + +def test_retry_task_returns_202(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """POST tasks/{id}/retry:HTTP 202,返回新 task_id 与 pending 状态""" + manager = FakeManager() + manager.retry_result = "new-task-7" + _inject_manager(monkeypatch, manager) + + resp = client.post("/api/v1/documents/tasks/t-failed/retry") + + assert resp.status_code == 202 + body = resp.json() + assert body["code"] == 0 + assert body["data"]["task_id"] == "new-task-7" + assert body["data"]["status"] == "pending" + + +def test_retry_task_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """POST tasks/{id}/retry:manager 返回 None → code=1004""" + manager = FakeManager() + manager.retry_result = None + _inject_manager(monkeypatch, manager) + + resp = client.post("/api/v1/documents/tasks/nope/retry") + + body = resp.json() + assert body["code"] == 1004 + + +def test_delete_task(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """DELETE tasks/{id}:删除成功 → deleted=true""" + manager = FakeManager() + manager.delete_result = True + _inject_manager(monkeypatch, manager) + + resp = client.delete("/api/v1/documents/tasks/t-x") + + body = resp.json() + assert body["code"] == 0 + assert body["data"]["task_id"] == "t-x" + assert body["data"]["deleted"] is True