From 52e14986d40132b2fa6a2e2d1ae12480243c8ce3 Mon Sep 17 00:00:00 2001 From: gqt <3217233537@qq.com> Date: Mon, 13 Jul 2026 11:54:54 +0800 Subject: [PATCH] feat: add document management workspace --- web/src/app/router.tsx | 5 +- web/src/features/documents/DocumentDetail.tsx | 45 ++++++++++ web/src/features/documents/DocumentList.tsx | 50 +++++++++++ web/src/features/documents/DocumentsPage.tsx | 71 +++++++++++++++ web/src/features/documents/UploadDocument.tsx | 39 ++++++++ web/src/features/documents/api.ts | 32 +++++++ web/src/features/documents/useDocuments.ts | 52 +++++++++++ web/src/index.css | 88 +++++++++++++++++++ 8 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 web/src/features/documents/DocumentDetail.tsx create mode 100644 web/src/features/documents/DocumentList.tsx create mode 100644 web/src/features/documents/DocumentsPage.tsx create mode 100644 web/src/features/documents/UploadDocument.tsx create mode 100644 web/src/features/documents/api.ts create mode 100644 web/src/features/documents/useDocuments.ts diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 56ab485..6e71c75 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -2,6 +2,7 @@ import { Navigate, createBrowserRouter } from 'react-router-dom' import { AppLayout } from './AppLayout' import { PlaceholderPage } from './PlaceholderPage' +import { DocumentsPage } from '../features/documents/DocumentsPage' export const router = createBrowserRouter([ { @@ -11,8 +12,8 @@ export const router = createBrowserRouter([ { index: true, element: }, { path: 'chat', element: }, { path: 'chat/:sessionId', element: }, - { path: 'documents', element: }, - { path: 'documents/:documentId', element: }, + { path: 'documents', element: }, + { path: 'documents/:documentId', element: }, ], }, ]) diff --git a/web/src/features/documents/DocumentDetail.tsx b/web/src/features/documents/DocumentDetail.tsx new file mode 100644 index 0000000..5757c8b --- /dev/null +++ b/web/src/features/documents/DocumentDetail.tsx @@ -0,0 +1,45 @@ +import { ChevronLeft, ChevronRight, FileSearch, X } from 'lucide-react' +import { useEffect, useState } from 'react' + +import { Button } from '../../components/ui/button' +import type { Chunk, Document } from '../../lib/types' +import { getDocument, listChunks } from './api' + +type Props = { documentId?: string; onClose?: () => void } + +export function DocumentDetail({ documentId, onClose }: Props) { + const [document, setDocument] = useState(null) + const [chunks, setChunks] = useState([]) + const [offset, setOffset] = useState(0) + const [total, setTotal] = useState(0) + const [error, setError] = useState(null) + + useEffect(() => { + if (!documentId) return + let active = true + void Promise.all([getDocument(documentId), listChunks(documentId, offset, 10)]) + .then(([nextDocument, page]) => { + if (!active) return + setDocument(nextDocument); setChunks(page.items); setTotal(page.total); setError(null) + }) + .catch((reason) => { if (active) setError(reason instanceof Error ? reason.message : '详情加载失败') }) + return () => { active = false } + }, [documentId, offset]) + + if (!documentId) return + return ( + + ) +} diff --git a/web/src/features/documents/DocumentList.tsx b/web/src/features/documents/DocumentList.tsx new file mode 100644 index 0000000..52d5383 --- /dev/null +++ b/web/src/features/documents/DocumentList.tsx @@ -0,0 +1,50 @@ +import { FileText, MoreHorizontal, RefreshCw, Trash2 } from 'lucide-react' + +import { Badge } from '../../components/ui/badge' +import { Button } from '../../components/ui/button' +import type { Document } from '../../lib/types' +import { cn } from '../../lib/utils' + +const STATUS_LABEL = { + pending: '等待索引', indexing: '索引中', indexed: '已索引', failed: '失败', deleting: '删除中', +} as const + +function formatSize(bytes: number) { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / 1024 ** 2).toFixed(1)} MB` +} + +type Props = { + documents: Document[] + selectedId?: string + onSelect: (id: string) => void + onRetry: (id: string) => void + onDelete: (id: string) => void +} + +export function DocumentList({ documents, selectedId, onSelect, onRetry, onDelete }: Props) { + if (documents.length === 0) { + return
资料库还是空的上传第一份文档,系统会在后台建立索引。
+ } + return ( +
+ {documents.map((document) => ( +
onSelect(document.id)}> +
{document.file_type.toUpperCase()}
+
+ {document.filename} + {formatSize(document.file_size)} · {document.chunk_count ? `${document.chunk_count} 个分块` : '尚无分块'} + {document.error_message && {document.error_message}} +
+ {STATUS_LABEL[document.status]} +
event.stopPropagation()}> + {document.status === 'failed' && } + +
+ +
+ ))} +
+ ) +} diff --git a/web/src/features/documents/DocumentsPage.tsx b/web/src/features/documents/DocumentsPage.tsx new file mode 100644 index 0000000..c409f5c --- /dev/null +++ b/web/src/features/documents/DocumentsPage.tsx @@ -0,0 +1,71 @@ +import * as Dialog from '@radix-ui/react-dialog' +import { Archive, FileCheck2, Filter, LoaderCircle, PanelRightOpen, TriangleAlert } from 'lucide-react' +import { useMemo, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' + +import { Button } from '../../components/ui/button' +import type { DocumentStatus } from '../../lib/types' +import { cn } from '../../lib/utils' +import { deleteDocument, retryDocument } from './api' +import { DocumentDetail } from './DocumentDetail' +import { DocumentList } from './DocumentList' +import { UploadDocument } from './UploadDocument' +import { useDocuments } from './useDocuments' + +const FILTERS: Array<{ value?: DocumentStatus; label: string }> = [ + { label: '全部资料' }, + { value: 'indexed', label: '已索引' }, + { value: 'indexing', label: '处理中' }, + { value: 'failed', label: '失败' }, +] + +export function DocumentsPage() { + const { documentId } = useParams() + const navigate = useNavigate() + const [filter, setFilter] = useState() + const [detailOpen, setDetailOpen] = useState(false) + const { documents, loading, error, refresh } = useDocuments(filter) + const counts = useMemo(() => ({ + indexed: documents.filter((item) => item.status === 'indexed').length, + active: documents.filter((item) => ['pending', 'indexing'].includes(item.status)).length, + failed: documents.filter((item) => item.status === 'failed').length, + }), [documents]) + + function selectDocument(id: string) { navigate(`/documents/${id}`); setDetailOpen(true) } + + async function retry(id: string) { await retryDocument(id); await refresh() } + async function remove(id: string) { + const document = documents.find((item) => item.id === id) + if (!window.confirm(`确定删除“${document?.filename ?? '此文档'}”及全部索引吗?`)) return + await deleteDocument(id) + if (documentId === id) navigate('/documents') + await refresh() + } + + return ( +
+
+
+

DOCUMENT LIBRARY

文档资料库

管理提供给研究 Agent 的唯一事实来源。

+ { selectDocument(id); void refresh() }} /> +
+
+
{documents.length} 当前列表
+
{counts.indexed} 已索引
+
{counts.active} 处理中
+
{counts.failed} 需处理
+
+
+
{FILTERS.map((item) => )}
+ {documentId && } +
+ {error &&

{error}

} + {loading ?
正在读取资料库…
: void retry(id)} onDelete={(id) => void remove(id)} />} +
+
+ + 文档详情 setDetailOpen(false)} /> + +
+ ) +} diff --git a/web/src/features/documents/UploadDocument.tsx b/web/src/features/documents/UploadDocument.tsx new file mode 100644 index 0000000..4d397bb --- /dev/null +++ b/web/src/features/documents/UploadDocument.tsx @@ -0,0 +1,39 @@ +import { FileUp, LoaderCircle } from 'lucide-react' +import { useRef, useState } from 'react' + +import { Button } from '../../components/ui/button' +import { uploadDocument } from './api' + +type Props = { onUploaded: (documentId: string) => void } + +export function UploadDocument({ onUploaded }: Props) { + const inputRef = useRef(null) + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + + async function handleFile(file?: File) { + if (!file) return + setUploading(true) + setError(null) + try { + const document = await uploadDocument(file) + onUploaded(document.id) + } catch (reason) { + setError(reason instanceof Error ? reason.message : '上传失败') + } finally { + setUploading(false) + if (inputRef.current) inputRef.current.value = '' + } + } + + return ( +
+ void handleFile(event.target.files?.[0])} /> + + {error &&

{error}

} +
+ ) +} diff --git a/web/src/features/documents/api.ts b/web/src/features/documents/api.ts new file mode 100644 index 0000000..cfd42e8 --- /dev/null +++ b/web/src/features/documents/api.ts @@ -0,0 +1,32 @@ +import { apiRequest } from '../../lib/api' +import type { Chunk, Document, DocumentStatus, Page } from '../../lib/types' + +export function listDocuments(status?: DocumentStatus) { + const params = new URLSearchParams({ limit: '100' }) + if (status) params.set('status', status) + return apiRequest>(`/api/v1/documents?${params}`) +} + +export function getDocument(documentId: string) { + return apiRequest(`/api/v1/documents/${documentId}`) +} + +export function listChunks(documentId: string, offset = 0, limit = 20) { + return apiRequest>( + `/api/v1/documents/${documentId}/chunks?offset=${offset}&limit=${limit}`, + ) +} + +export function uploadDocument(file: File) { + const form = new FormData() + form.append('file', file) + return apiRequest('/api/v1/documents', { method: 'POST', body: form }) +} + +export function retryDocument(documentId: string) { + return apiRequest(`/api/v1/documents/${documentId}/retry`, { method: 'POST' }) +} + +export function deleteDocument(documentId: string) { + return apiRequest(`/api/v1/documents/${documentId}`, { method: 'DELETE' }) +} diff --git a/web/src/features/documents/useDocuments.ts b/web/src/features/documents/useDocuments.ts new file mode 100644 index 0000000..fc8f925 --- /dev/null +++ b/web/src/features/documents/useDocuments.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { Document, DocumentStatus } from '../../lib/types' +import { listDocuments } from './api' + +const ACTIVE = new Set(['pending', 'indexing', 'deleting']) + +export function useDocuments(filter?: DocumentStatus) { + const [documents, setDocuments] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const mounted = useRef(true) + + const refresh = useCallback(async () => { + try { + const page = await listDocuments(filter) + if (mounted.current) { + setDocuments(page.items) + setError(null) + } + } catch (reason) { + if (mounted.current) setError(reason instanceof Error ? reason.message : '文档加载失败') + } finally { + if (mounted.current) setLoading(false) + } + }, [filter]) + + useEffect(() => { + mounted.current = true + listDocuments(filter) + .then((page) => { + if (!mounted.current) return + setDocuments(page.items) + setError(null) + }) + .catch((reason) => { + if (mounted.current) setError(reason instanceof Error ? reason.message : '文档加载失败') + }) + .finally(() => { + if (mounted.current) setLoading(false) + }) + return () => { mounted.current = false } + }, [filter]) + + useEffect(() => { + if (!documents.some((document) => ACTIVE.has(document.status))) return + const timer = window.setInterval(() => void refresh(), 2_000) + return () => window.clearInterval(timer) + }, [documents, refresh]) + + return { documents, loading, error, refresh } +} diff --git a/web/src/index.css b/web/src/index.css index ccba606..bf36a1a 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -72,6 +72,76 @@ a { color: inherit; text-decoration: none; } .mobile-menu { position: fixed; inset: 0 auto 0 0; z-index: 51; width: min(310px, 88vw); padding: 22px 16px; color: #edf3ef; background: var(--forest); box-shadow: 24px 0 80px rgba(0,0,0,.3); animation: slide-in .25s ease; } .mobile-menu__close { position: absolute; top: 12px; right: 10px; z-index: 2; } +.documents-workspace { display: grid; grid-template-columns: minmax(520px, 1fr) 340px; min-height: 100vh; } +.documents-main { min-width: 0; padding: 34px clamp(24px, 4vw, 58px); overflow: hidden; } +.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; } +.page-header h1 { margin: 7px 0 6px; font-family: "Songti SC", Georgia, serif; font-size: clamp(2rem, 3vw, 3rem); font-weight: 600; letter-spacing: -.045em; } +.page-header p:not(.eyebrow) { margin: 0; color: #7b8782; font-size: .84rem; } +.upload-control { display: flex; align-items: center; gap: 10px; } +.inline-error { position: absolute; margin-top: 68px; max-width: 260px; color: var(--danger); font-size: .7rem; } +.metric-strip { margin: 30px 0 24px; padding: 13px 18px; display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); border-radius: 11px; background: rgba(255,255,255,.6); box-shadow: 0 12px 40px rgba(38,54,48,.04); } +.metric-strip > div { display: flex; align-items: center; gap: 9px; min-width: 0; padding: 5px 14px; color: #75817d; border-right: 1px solid var(--line); } +.metric-strip > div:first-child { padding-left: 0; } +.metric-strip > div:last-child { border: 0; } +.metric-strip svg { flex: none; color: var(--ember); } +.metric-strip span { font-size: .7rem; white-space: nowrap; } +.metric-strip strong { margin-right: 3px; color: var(--ink); font-size: 1rem; } +.document-toolbar { min-height: 42px; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.filter-tabs { display: flex; align-items: center; gap: 3px; padding: 3px; border-radius: 8px; color: #77847f; background: #eaece8; } +.filter-tabs > svg { margin: 0 5px 0 4px; } +.filter-tabs button { min-height: 29px; padding: 0 10px; color: inherit; border: 0; border-radius: 6px; background: transparent; cursor: pointer; font-size: .68rem; } +.filter-tabs button.is-active { color: var(--ink); background: white; box-shadow: 0 2px 9px rgba(35,51,45,.08); } +.detail-trigger { display: none; } +.document-list { display: grid; gap: 7px; } +.document-row { position: relative; min-height: 74px; padding: 12px 45px 12px 12px; display: grid; grid-template-columns: 42px minmax(140px, 1fr) auto auto; align-items: center; gap: 12px; border: 1px solid transparent; border-radius: 10px; cursor: pointer; transition: border .16s ease, background .16s ease, transform .16s ease; } +.document-row:hover { background: rgba(255,255,255,.7); transform: translateY(-1px); } +.document-row--selected { border-color: #c9d4ce; background: white; box-shadow: 0 8px 28px rgba(40,56,50,.07); } +.file-glyph { width: 42px; height: 45px; display: grid; place-items: center; color: #9a583a; border: 1px solid #e4c9b9; border-radius: 7px 7px 3px 7px; background: #fff5ef; font-size: .57rem; font-weight: 800; letter-spacing: .05em; } +.document-row__body { min-width: 0; } +.document-row__body strong, .document-row__body span, .document-row__body small { display: block; } +.document-row__body strong { overflow: hidden; color: #263a33; font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; } +.document-row__body span { margin-top: 5px; color: #83908b; font-size: .66rem; } +.document-row__body small { margin-top: 4px; overflow: hidden; color: var(--danger); font-size: .62rem; text-overflow: ellipsis; white-space: nowrap; } +.status > span { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.status--indexed { color: #38775e; background: #e5f1eb; } +.status--indexing, .status--pending { color: #9b653f; background: #f8eadf; } +.status--failed { color: #a4433c; background: #f8e5e3; } +.status--deleting { color: #697671; } +.row-actions { display: flex; align-items: center; } +.row-actions .button { color: #74817c; } +.row-cue { position: absolute; right: 14px; color: #a8b1ad; } +.list-empty, .loading-state { min-height: 310px; display: grid; place-content: center; justify-items: center; gap: 8px; color: #8a9691; text-align: center; } +.list-empty svg { color: var(--ember); } +.list-empty strong { color: var(--ink); font-family: "Songti SC", Georgia, serif; font-size: 1.05rem; } +.list-empty span { max-width: 280px; font-size: .74rem; } +.loading-state { grid-auto-flow: column; font-size: .76rem; } +.spin { animation: spin .9s linear infinite; } +.panel-error { padding: 10px 12px; color: var(--danger); border: 1px solid #e8c6c2; border-radius: 7px; background: #fff0ee; font-size: .72rem; } +.documents-detail-desktop { min-width: 0; border-left: 1px solid var(--line); background: #f0f3f1; } +.detail-panel { position: sticky; top: 0; height: 100vh; padding: 28px 22px 18px; display: flex; flex-direction: column; overflow: hidden; } +.detail-panel--empty { align-items: center; justify-content: center; gap: 7px; color: #84908b; text-align: center; } +.detail-panel--empty svg { margin-bottom: 5px; color: var(--ember); } +.detail-panel--empty strong { color: var(--ink); font-family: "Songti SC", Georgia, serif; } +.detail-panel--empty span { max-width: 230px; font-size: .7rem; line-height: 1.6; } +.detail-panel__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.detail-panel__header h2 { margin: 7px 0 0; overflow: hidden; font-family: "Songti SC", Georgia, serif; font-size: 1.25rem; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.document-meta { margin: 20px 0 16px; padding: 10px 0; display: grid; grid-template-columns: repeat(3, 1fr); border-block: 1px solid var(--line); } +.document-meta div { padding: 0 8px; border-right: 1px solid var(--line); } +.document-meta div:first-child { padding-left: 0; } +.document-meta div:last-child { border: 0; } +.document-meta dt { color: #8a9591; font-size: .58rem; text-transform: uppercase; } +.document-meta dd { margin: 4px 0 0; color: var(--ink); font-size: .7rem; font-weight: 700; } +.chunk-list { min-height: 0; flex: 1; display: grid; align-content: start; gap: 9px; overflow-y: auto; scrollbar-width: thin; } +.chunk-card { padding: 11px 12px; border: 1px solid #d9dfdc; border-radius: 8px; background: rgba(255,255,255,.72); } +.chunk-card > div { display: flex; gap: 7px; color: var(--ember-dark); font-size: .58rem; font-weight: 800; letter-spacing: .04em; } +.chunk-card p { margin: 7px 0 0; display: -webkit-box; overflow: hidden; color: #50605a; font-family: "Songti SC", Georgia, serif; font-size: .72rem; line-height: 1.7; -webkit-box-orient: vertical; -webkit-line-clamp: 5; } +.muted-copy { color: #8a9691; font-size: .72rem; line-height: 1.7; } +.detail-pagination { padding-top: 9px; display: flex; align-items: center; justify-content: center; gap: 10px; color: #84908b; border-top: 1px solid var(--line); font-size: .65rem; } +.evidence-drawer { position: fixed; inset: 0 0 0 auto; z-index: 51; width: min(390px, 92vw); background: #f0f3f1; box-shadow: -24px 0 80px rgba(0,0,0,.2); animation: drawer-in .25s ease; } + +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes drawer-in { from { transform: translateX(100%); } } + @keyframes page-enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } @keyframes fade-in { from { opacity: 0; } } @keyframes slide-in { from { transform: translateX(-100%); } } @@ -86,6 +156,24 @@ a { color: inherit; text-decoration: none; } .route-canvas { min-height: calc(100vh - 58px); } } +@media (max-width: 1099px) { + .documents-workspace { display: block; } + .documents-detail-desktop { display: none; } + .detail-trigger { display: inline-flex; } +} + +@media (max-width: 640px) { + .documents-main { padding: 24px 16px; } + .page-header { display: grid; } + .metric-strip { grid-template-columns: 1fr 1fr; gap: 7px; } + .metric-strip > div { border: 0; padding: 5px; } + .document-toolbar { align-items: flex-start; } + .filter-tabs { max-width: 100%; overflow-x: auto; } + .document-row { grid-template-columns: 40px minmax(0, 1fr); padding-right: 38px; } + .document-row .status { grid-column: 2; justify-self: start; } + .row-actions { grid-column: 2; } +} + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } }