feat: add document management workspace

This commit is contained in:
gqt
2026-07-13 11:54:54 +08:00
parent 2c220a0b5f
commit 52e14986d4
8 changed files with 380 additions and 2 deletions
+3 -2
View File
@@ -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: <Navigate to="/chat" replace /> },
{ path: 'chat', element: <PlaceholderPage /> },
{ path: 'chat/:sessionId', element: <PlaceholderPage /> },
{ path: 'documents', element: <PlaceholderPage /> },
{ path: 'documents/:documentId', element: <PlaceholderPage /> },
{ path: 'documents', element: <DocumentsPage /> },
{ path: 'documents/:documentId', element: <DocumentsPage /> },
],
},
])
@@ -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<Document | null>(null)
const [chunks, setChunks] = useState<Chunk[]>([])
const [offset, setOffset] = useState(0)
const [total, setTotal] = useState(0)
const [error, setError] = useState<string | null>(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 <aside className="detail-panel detail-panel--empty"><FileSearch size={24} /><strong></strong><span></span></aside>
return (
<aside className="detail-panel">
<header className="detail-panel__header">
<div><p className="eyebrow">DOCUMENT DETAIL</p><h2>{document?.filename ?? '加载中…'}</h2></div>
{onClose && <Button variant="ghost" size="icon" onClick={onClose} aria-label="关闭详情"><X size={17} /></Button>}
</header>
{error && <p className="panel-error">{error}</p>}
{document && <dl className="document-meta"><div><dt></dt><dd>{document.status}</dd></div><div><dt></dt><dd>{document.chunk_count}</dd></div><div><dt></dt><dd>{document.file_type.toUpperCase()}</dd></div></dl>}
<div className="chunk-list">
{chunks.map((chunk) => <article key={chunk.id} className="chunk-card"><div><span>#{chunk.order_index + 1}</span>{chunk.page_number && <span> {chunk.page_number} </span>}</div><p>{chunk.content}</p></article>)}
{document?.status !== 'indexed' && chunks.length === 0 && <p className="muted-copy"></p>}
</div>
{total > 10 && <footer className="detail-pagination"><Button variant="ghost" size="icon" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - 10))}><ChevronLeft /></Button><span>{offset + 1}{Math.min(offset + 10, total)} / {total}</span><Button variant="ghost" size="icon" disabled={offset + 10 >= total} onClick={() => setOffset(offset + 10)}><ChevronRight /></Button></footer>}
</aside>
)
}
@@ -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 <div className="list-empty"><FileText size={24} /><strong></strong><span></span></div>
}
return (
<div className="document-list">
{documents.map((document) => (
<article key={document.id} className={cn('document-row', selectedId === document.id && 'document-row--selected')} onClick={() => onSelect(document.id)}>
<div className="file-glyph">{document.file_type.toUpperCase()}</div>
<div className="document-row__body">
<strong title={document.filename}>{document.filename}</strong>
<span>{formatSize(document.file_size)} · {document.chunk_count ? `${document.chunk_count} 个分块` : '尚无分块'}</span>
{document.error_message && <small>{document.error_message}</small>}
</div>
<Badge className={`status status--${document.status}`}><span />{STATUS_LABEL[document.status]}</Badge>
<div className="row-actions" onClick={(event) => event.stopPropagation()}>
{document.status === 'failed' && <Button variant="ghost" size="icon" title="重试" onClick={() => onRetry(document.id)}><RefreshCw size={15} /></Button>}
<Button variant="ghost" size="icon" title="删除" disabled={document.status === 'deleting'} onClick={() => onDelete(document.id)}><Trash2 size={15} /></Button>
</div>
<MoreHorizontal className="row-cue" size={16} />
</article>
))}
</div>
)
}
@@ -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<DocumentStatus | undefined>()
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 (
<section className="documents-workspace">
<main className="documents-main">
<header className="page-header">
<div><p className="eyebrow">DOCUMENT LIBRARY</p><h1></h1><p> Agent </p></div>
<UploadDocument onUploaded={(id) => { selectDocument(id); void refresh() }} />
</header>
<div className="metric-strip">
<div><Archive size={17} /><span><strong>{documents.length}</strong> </span></div>
<div><FileCheck2 size={17} /><span><strong>{counts.indexed}</strong> </span></div>
<div><LoaderCircle size={17} /><span><strong>{counts.active}</strong> </span></div>
<div><TriangleAlert size={17} /><span><strong>{counts.failed}</strong> </span></div>
</div>
<div className="document-toolbar">
<div className="filter-tabs"><Filter size={14} />{FILTERS.map((item) => <button key={item.label} className={cn(filter === item.value && 'is-active')} onClick={() => setFilter(item.value)}>{item.label}</button>)}</div>
{documentId && <Button className="detail-trigger" variant="secondary" size="sm" onClick={() => setDetailOpen(true)}><PanelRightOpen size={15} /></Button>}
</div>
{error && <p className="panel-error">{error}</p>}
{loading ? <div className="loading-state"><LoaderCircle className="spin" /></div> : <DocumentList documents={documents} selectedId={documentId} onSelect={selectDocument} onRetry={(id) => void retry(id)} onDelete={(id) => void remove(id)} />}
</main>
<div className="documents-detail-desktop"><DocumentDetail key={documentId ?? 'empty'} documentId={documentId} /></div>
<Dialog.Root open={detailOpen} onOpenChange={setDetailOpen}>
<Dialog.Portal><Dialog.Overlay className="dialog-overlay" /><Dialog.Content className="evidence-drawer"><Dialog.Title className="sr-only"></Dialog.Title><DocumentDetail key={`drawer-${documentId ?? 'empty'}`} documentId={documentId} onClose={() => setDetailOpen(false)} /></Dialog.Content></Dialog.Portal>
</Dialog.Root>
</section>
)
}
@@ -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<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="upload-control">
<input ref={inputRef} className="sr-only" type="file" accept=".pdf,.md,.markdown,.txt" onChange={(event) => void handleFile(event.target.files?.[0])} />
<Button onClick={() => inputRef.current?.click()} disabled={uploading}>
{uploading ? <LoaderCircle className="spin" size={16} /> : <FileUp size={16} />}
{uploading ? '正在上传' : '上传文档'}
</Button>
{error && <p className="inline-error" role="alert">{error}</p>}
</div>
)
}
+32
View File
@@ -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<Page<Document>>(`/api/v1/documents?${params}`)
}
export function getDocument(documentId: string) {
return apiRequest<Document>(`/api/v1/documents/${documentId}`)
}
export function listChunks(documentId: string, offset = 0, limit = 20) {
return apiRequest<Page<Chunk>>(
`/api/v1/documents/${documentId}/chunks?offset=${offset}&limit=${limit}`,
)
}
export function uploadDocument(file: File) {
const form = new FormData()
form.append('file', file)
return apiRequest<Document>('/api/v1/documents', { method: 'POST', body: form })
}
export function retryDocument(documentId: string) {
return apiRequest<Document>(`/api/v1/documents/${documentId}/retry`, { method: 'POST' })
}
export function deleteDocument(documentId: string) {
return apiRequest<void>(`/api/v1/documents/${documentId}`, { method: 'DELETE' })
}
@@ -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<DocumentStatus>(['pending', 'indexing', 'deleting'])
export function useDocuments(filter?: DocumentStatus) {
const [documents, setDocuments] = useState<Document[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 }
}
+88
View File
@@ -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; }
}