add
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Upload, Search } from 'lucide-react';
|
||||
import { Upload, Search, Download, Trash2, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
import { UploadModal } from './UploadModal';
|
||||
|
||||
interface Doc {
|
||||
@@ -10,30 +10,55 @@ interface Doc {
|
||||
uploadedAt: string;
|
||||
chunks: number;
|
||||
type: string;
|
||||
sizeBytes: number;
|
||||
summary?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
const STATUS_FILTERS = ['All', 'Ready', 'Embedding', 'Failed', 'Pending'];
|
||||
const TYPE_OPTS = ['All types', 'EU Regulation', 'ISO Standard', 'National Draft', 'Internal Policy'];
|
||||
|
||||
const MOCK_DOCS: Doc[] = [
|
||||
{ id: '1', name: 'EU AI Act — Full text (EN)', status: 'ok', uploadedAt: '2025-11-10', chunks: 842, type: 'EU Regulation' },
|
||||
{ id: '2', name: 'MIIT Draft 2025-08 (ZH)', status: 'ok', uploadedAt: '2025-11-01', chunks: 320, type: 'National Draft' },
|
||||
{ id: '3', name: 'ISO/SAE 21434:2021', status: 'ok', uploadedAt: '2025-10-15', chunks: 614, type: 'ISO Standard' },
|
||||
{ id: '4', name: 'Vehicle AI Safety Manual v3.2', status: 'ok', uploadedAt: '2025-10-08', chunks: 198, type: 'Internal Policy' },
|
||||
{ id: '5', name: 'ADAS System Requirements', status: 'warn', uploadedAt: '2025-09-22', chunks: 0, type: 'Internal Policy' },
|
||||
{ id: '6', name: 'UNECE R155 Corrigendum', status: 'info', uploadedAt: '2025-09-12', chunks: 87, type: 'EU Regulation' },
|
||||
{ id: '7', name: 'GB/T 42118-2022', status: 'risk', uploadedAt: '2025-08-30', chunks: 0, type: 'National Draft' },
|
||||
];
|
||||
|
||||
const STATUS_FILTERS = ['All', 'Ready', 'Processing', 'Failed', 'Pending'];
|
||||
const STATUS_LABEL: Record<string, string> = { ok: 'Ready', warn: 'Processing', risk: 'Failed', info: 'Pending' };
|
||||
const STATUS_MAP: Record<string, string> = { All: 'All', Ready: 'ok', Embedding: 'warn', Failed: 'risk', Pending: 'info' };
|
||||
const STATUS_MAP: Record<string, string> = { All: 'All', Ready: 'ok', Processing: 'warn', Failed: 'risk', Pending: 'info' };
|
||||
|
||||
// Map backend DocumentStatus enum values to frontend display status
|
||||
function backendStatus(s: string): Doc['status'] {
|
||||
if (s === 'indexed') return 'ok';
|
||||
if (s === 'failed') return 'risk';
|
||||
if (s === 'parsed') return 'warn'; // chunked, awaiting embedding
|
||||
return 'info'; // pending / stored
|
||||
if (s === 'parsed') return 'warn';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '—';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// ── Confirm dialog ─────────────────────────────────────────────────────────
|
||||
function ConfirmDialog({ message, onConfirm, onCancel }: {
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', maxWidth: 400, width: '100%', boxShadow: '0 12px 40px rgba(0,0,0,.2)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
<AlertTriangle size={18} color="var(--danger)" />
|
||||
<span style={{ fontWeight: 600, fontSize: 15 }}>Confirm deletion</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 20 }}>{message}</p>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button className="btn sm" onClick={onCancel}>Cancel</button>
|
||||
<button className="btn sm" style={{ background: 'var(--danger)', color: '#fff', borderColor: 'var(--danger)' }} onClick={onConfirm}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsPage() {
|
||||
@@ -41,14 +66,23 @@ export function DocsPage() {
|
||||
const [statusF, setStatusF] = useState('All');
|
||||
const [typeF, setTypeF] = useState('All types');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [docs, setDocs] = useState<Doc[]>(MOCK_DOCS);
|
||||
const [docs, setDocs] = useState<Doc[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [retrying, setRetrying] = useState<Set<string>>(new Set());
|
||||
const [deleting, setDeleting] = useState<Set<string>>(new Set());
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ ids: string[]; names: string[] } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Dynamic type options derived from actual docs
|
||||
const typeOpts = ['All types', ...Array.from(new Set(docs.map(d => d.type).filter(t => t && t !== '—')))];
|
||||
|
||||
const fetchDocs = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch('/api/v1/documents/management-list')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (!Array.isArray(d?.documents)) return;
|
||||
if (!Array.isArray(d?.documents)) { setLoading(false); return; }
|
||||
setDocs(d.documents.map((item: Record<string, unknown>) => ({
|
||||
id: item.doc_id as string,
|
||||
name: item.doc_name as string,
|
||||
@@ -56,11 +90,18 @@ export function DocsPage() {
|
||||
uploadedAt: ((item.updated_at as string) ?? '').slice(0, 10),
|
||||
chunks: (item.chunk_count as number) ?? 0,
|
||||
type: (item.regulation_type as string) || '—',
|
||||
sizeBytes: (item.size_bytes as number) ?? 0,
|
||||
summary: item.summary as string | undefined,
|
||||
version: item.version as string | undefined,
|
||||
})));
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchDocs(); }, [fetchDocs, refreshKey]);
|
||||
|
||||
// ── Filtering ────────────────────────────────────────────────────────────
|
||||
const filtered = docs.filter(d => {
|
||||
const matchSearch = !search || d.name.toLowerCase().includes(search.toLowerCase());
|
||||
const matchStatus = statusF === 'All' || d.status === STATUS_MAP[statusF];
|
||||
@@ -68,17 +109,60 @@ export function DocsPage() {
|
||||
return matchSearch && matchStatus && matchType;
|
||||
});
|
||||
|
||||
// ── Selection helpers ────────────────────────────────────────────────────
|
||||
function toggleAll() {
|
||||
if (selected.size === filtered.length) setSelected(new Set());
|
||||
else setSelected(new Set(filtered.map(d => d.id)));
|
||||
}
|
||||
|
||||
function toggleOne(id: string) {
|
||||
const s = new Set(selected);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
setSelected(s);
|
||||
}
|
||||
|
||||
// ── Download ─────────────────────────────────────────────────────────────
|
||||
function downloadDoc(id: string, name: string) {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/v1/documents/download/${id}`;
|
||||
a.download = name;
|
||||
a.click();
|
||||
}
|
||||
|
||||
// ── Retry (re-process failed doc) ────────────────────────────────────────
|
||||
async function retryDoc(id: string) {
|
||||
setRetrying(r => new Set([...r, id]));
|
||||
try {
|
||||
await fetch(`/api/v1/documents/${id}/retry`, { method: 'POST' });
|
||||
setTimeout(() => {
|
||||
setRetrying(r => { const s = new Set(r); s.delete(id); return s; });
|
||||
setRefreshKey(k => k + 1);
|
||||
}, 1500);
|
||||
} catch {
|
||||
setRetrying(r => { const s = new Set(r); s.delete(id); return s; });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete (single or batch) ─────────────────────────────────────────────
|
||||
function askDelete(ids: string[]) {
|
||||
const names = ids.map(id => docs.find(d => d.id === id)?.name ?? id);
|
||||
setConfirmDelete({ ids, names });
|
||||
}
|
||||
|
||||
async function confirmDeleteDocs() {
|
||||
if (!confirmDelete) return;
|
||||
const { ids } = confirmDelete;
|
||||
setConfirmDelete(null);
|
||||
setDeleting(new Set(ids));
|
||||
|
||||
await Promise.allSettled(
|
||||
ids.map(id => fetch(`/api/v1/documents/${id}`, { method: 'DELETE' }))
|
||||
);
|
||||
|
||||
setDeleting(new Set());
|
||||
setSelected(s => { const n = new Set(s); ids.forEach(id => n.delete(id)); return n; });
|
||||
setRefreshKey(k => k + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="docs-page">
|
||||
<Topbar
|
||||
@@ -93,12 +177,16 @@ export function DocsPage() {
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
||||
<RefreshCw size={13} />Refresh
|
||||
</button>
|
||||
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
||||
<Upload size={13} />Upload document
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="page-content">
|
||||
<div className="docs-controls">
|
||||
<div className="chip-group">
|
||||
@@ -111,18 +199,25 @@ export function DocsPage() {
|
||||
))}
|
||||
</div>
|
||||
<select className="select-input" value={typeF} onChange={e => setTypeF(e.target.value)}>
|
||||
{TYPE_OPTS.map(o => <option key={o}>{o}</option>)}
|
||||
{typeOpts.map(o => <option key={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Batch action bar */}
|
||||
{selected.size > 0 && (
|
||||
<div className="batch-bar">
|
||||
<span>{selected.size} document{selected.size > 1 ? 's' : ''} selected</span>
|
||||
<button className="btn sm">Analyze selected</button>
|
||||
<button className="btn sm risk-btn">Delete selected</button>
|
||||
<button
|
||||
className="btn sm"
|
||||
style={{ color: 'var(--danger)', borderColor: 'rgba(239,68,68,.4)' }}
|
||||
onClick={() => askDelete([...selected])}
|
||||
>
|
||||
<Trash2 size={12} />Delete selected
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="docs-table">
|
||||
<div className="table-header">
|
||||
<input
|
||||
@@ -134,32 +229,110 @@ export function DocsPage() {
|
||||
<span>Status</span>
|
||||
<span>Uploaded</span>
|
||||
<span>Chunks</span>
|
||||
<span>Size</span>
|
||||
<span>Type</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{filtered.map(d => (
|
||||
<div key={d.id} className={`table-row${selected.has(d.id) ? ' row-selected' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(d.id)}
|
||||
onChange={() => toggleOne(d.id)}
|
||||
/>
|
||||
<span className="doc-name-cell">{d.name}</span>
|
||||
<span><span className={`status ${d.status}`}>{STATUS_LABEL[d.status]}</span></span>
|
||||
<span className="cell-mono">{d.uploadedAt}</span>
|
||||
<span className="cell-mono">{d.chunks || '—'}</span>
|
||||
<span className="cell-muted">{d.type}</span>
|
||||
<span className="row-actions">
|
||||
<button className="text-link">Inspect</button>
|
||||
<button className="text-link">Analyze</button>
|
||||
{d.status === 'risk' && <button className="text-link danger-link">Resolve</button>}
|
||||
</span>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px 16px', color: 'var(--muted)', fontSize: 13, textAlign: 'center' }}>
|
||||
Loading documents…
|
||||
</div>
|
||||
))}
|
||||
) : filtered.length === 0 ? (
|
||||
<div style={{ padding: '40px 16px', color: 'var(--muted)', fontSize: 13, textAlign: 'center' }}>
|
||||
{docs.length === 0 ? 'No documents yet. Upload a document to get started.' : 'No documents match the current filters.'}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(d => {
|
||||
const isDeleting = deleting.has(d.id);
|
||||
const isRetrying = retrying.has(d.id);
|
||||
return (
|
||||
<div
|
||||
key={d.id}
|
||||
className={`table-row${selected.has(d.id) ? ' row-selected' : ''}${isDeleting ? ' row-deleting' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(d.id)}
|
||||
onChange={() => toggleOne(d.id)}
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
<span className="doc-name-cell" title={d.summary || d.name}>
|
||||
{d.name}
|
||||
{d.version && <span style={{ fontSize: 10, color: 'var(--muted)', marginLeft: 6 }}>v{d.version}</span>}
|
||||
</span>
|
||||
<span><span className={`status ${d.status}`}>{STATUS_LABEL[d.status]}</span></span>
|
||||
<span className="cell-mono">{d.uploadedAt}</span>
|
||||
<span className="cell-mono">{d.chunks || '—'}</span>
|
||||
<span className="cell-mono">{formatSize(d.sizeBytes)}</span>
|
||||
<span className="cell-muted">{d.type}</span>
|
||||
<span className="row-actions">
|
||||
{/* Download */}
|
||||
<button
|
||||
className="text-link"
|
||||
title="Download original file"
|
||||
onClick={() => downloadDoc(d.id, d.name)}
|
||||
>
|
||||
<Download size={12} />
|
||||
</button>
|
||||
|
||||
{/* Retry for failed */}
|
||||
{d.status === 'risk' && (
|
||||
<button
|
||||
className="text-link"
|
||||
title="Retry processing"
|
||||
disabled={isRetrying}
|
||||
onClick={() => retryDoc(d.id)}
|
||||
style={{ color: 'var(--warn)' }}
|
||||
>
|
||||
<RefreshCw size={12} style={{ animation: isRetrying ? 'spin 1s linear infinite' : 'none' }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
className="text-link danger-link"
|
||||
title="Delete document"
|
||||
disabled={isDeleting}
|
||||
onClick={() => askDelete([d.id])}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer count */}
|
||||
{!loading && (
|
||||
<div style={{ padding: '10px 0', fontSize: 12, color: 'var(--muted)' }}>
|
||||
{filtered.length} of {docs.length} document{docs.length !== 1 ? 's' : ''}
|
||||
{selected.size > 0 && ` · ${selected.size} selected`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
|
||||
{/* Confirm delete dialog */}
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
message={
|
||||
confirmDelete.ids.length === 1
|
||||
? `Delete "${confirmDelete.names[0]}"? This will remove the document, all its chunks, and embeddings from the vector store. This action cannot be undone.`
|
||||
: `Delete ${confirmDelete.ids.length} documents? This will remove them and all their chunks from the vector store. This action cannot be undone.`
|
||||
}
|
||||
onConfirm={confirmDeleteDocs}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showUpload && (
|
||||
<UploadModal
|
||||
onClose={() => setShowUpload(false)}
|
||||
onComplete={() => setRefreshKey(k => k + 1)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user