2026-05-14 15:07:34 +08:00
|
|
|
|
import React, { useEffect, useRef, useState } from 'react';
|
2026-05-18 16:32:42 +08:00
|
|
|
|
import { useTheme } from '../../contexts';
|
2026-05-14 15:07:34 +08:00
|
|
|
|
import { Content } from '../../components/layout/Content';
|
|
|
|
|
|
import { TPattern } from '../../components/common/TPattern';
|
2026-05-20 23:34:08 +08:00
|
|
|
|
import { getDocumentList, getDocumentStatus, searchRegulations, uploadDocument, deleteDocument, retryDocument, type RegulationSearchItem } from '../../api/docs';
|
2026-05-14 15:07:34 +08:00
|
|
|
|
import type { Doc } from '../../types';
|
|
|
|
|
|
|
|
|
|
|
|
type PipelineStatus = 'idle' | 'running' | 'completed' | 'error';
|
|
|
|
|
|
|
|
|
|
|
|
const PIPELINE_STEPS = [
|
|
|
|
|
|
{ name: 'LOAD' },
|
|
|
|
|
|
{ name: 'PARSE' },
|
|
|
|
|
|
{ name: 'CHUNK' },
|
|
|
|
|
|
{ name: 'EMBED' },
|
|
|
|
|
|
{ name: 'STORE' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
const REGULATION_TYPES = ['', '国家标准', '行业标准', '地方标准', '企业标准', '法律法规', '监管规定'];
|
|
|
|
|
|
|
2026-05-14 15:07:34 +08:00
|
|
|
|
const STEP_DURATION_MS = 700;
|
2026-05-18 16:32:42 +08:00
|
|
|
|
const INITIAL_SEARCH_QUERY = '新能源汽车电池安全要求';
|
2026-05-14 15:07:34 +08:00
|
|
|
|
|
|
|
|
|
|
function wait(ms: number) {
|
2026-05-20 23:34:08 +08:00
|
|
|
|
return new Promise<void>((resolve) => { window.setTimeout(resolve, ms); });
|
2026-05-14 15:07:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const DocsPage: React.FC = () => {
|
|
|
|
|
|
const { theme, isDark } = useTheme();
|
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
const pipelineRunIdRef = useRef(0);
|
|
|
|
|
|
|
|
|
|
|
|
const [activeStep, setActiveStep] = useState<number>(-1);
|
|
|
|
|
|
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
|
|
|
|
|
|
const [pipelineStatus, setPipelineStatus] = useState<PipelineStatus>('idle');
|
|
|
|
|
|
const [docs, setDocs] = useState<Doc[]>([]);
|
|
|
|
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
|
|
const [uploadFileName, setUploadFileName] = useState('');
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
2026-05-18 16:32:42 +08:00
|
|
|
|
const [searchQuery, setSearchQuery] = useState(INITIAL_SEARCH_QUERY);
|
2026-05-14 15:07:34 +08:00
|
|
|
|
const [searchResults, setSearchResults] = useState<RegulationSearchItem[]>([]);
|
|
|
|
|
|
const [searchLoading, setSearchLoading] = useState(false);
|
|
|
|
|
|
const [searchError, setSearchError] = useState('');
|
|
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
// Upload metadata
|
|
|
|
|
|
const [regulationType, setRegulationType] = useState('');
|
|
|
|
|
|
const [version, setVersion] = useState('');
|
|
|
|
|
|
|
|
|
|
|
|
// Batch queue: files waiting to be uploaded after the current one finishes
|
|
|
|
|
|
const batchQueueRef = useRef<File[]>([]);
|
|
|
|
|
|
|
2026-05-18 16:32:42 +08:00
|
|
|
|
async function loadDocuments() {
|
2026-05-14 15:07:34 +08:00
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await getDocumentList();
|
|
|
|
|
|
const apiDocs: Doc[] = response.docs.map((doc) => ({
|
|
|
|
|
|
id: parseInt(String(doc.id).replace('doc-', ''), 10) || Math.floor(Math.random() * 10000),
|
|
|
|
|
|
name: doc.name,
|
|
|
|
|
|
chunks: doc.chunks,
|
2026-05-18 16:32:42 +08:00
|
|
|
|
size: doc.updated_at ? new Date(doc.updated_at).toLocaleString() : 'Indexed document',
|
|
|
|
|
|
status: doc.status === 'indexed' ? 'indexed' : doc.status === 'failed' ? 'failed' : 'parsing',
|
2026-05-14 15:07:34 +08:00
|
|
|
|
docId: doc.id,
|
|
|
|
|
|
downloadUrl: doc.download_url,
|
2026-05-18 16:32:42 +08:00
|
|
|
|
updatedAt: doc.updated_at,
|
2026-05-20 23:34:08 +08:00
|
|
|
|
summary: doc.summary,
|
|
|
|
|
|
regulationType: doc.regulation_type,
|
|
|
|
|
|
version: doc.version,
|
2026-05-14 15:07:34 +08:00
|
|
|
|
}));
|
|
|
|
|
|
setDocs(apiDocs);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load documents:', error);
|
|
|
|
|
|
setDocs([]);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
2026-05-18 16:32:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function runSearch(query: string) {
|
|
|
|
|
|
if (!query.trim()) return;
|
|
|
|
|
|
setSearchLoading(true);
|
|
|
|
|
|
setSearchError('');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await searchRegulations(query.trim(), 8);
|
|
|
|
|
|
setSearchResults(response.results);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to search regulations:', error);
|
|
|
|
|
|
setSearchError(error instanceof Error ? error.message : '检索失败');
|
|
|
|
|
|
setSearchResults([]);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSearchLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-20 23:34:08 +08:00
|
|
|
|
const timerId = window.setTimeout(() => { void loadDocuments(); }, 0);
|
2026-05-18 16:32:42 +08:00
|
|
|
|
return () => window.clearTimeout(timerId);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-20 23:34:08 +08:00
|
|
|
|
const timerId = window.setTimeout(() => { void runSearch(INITIAL_SEARCH_QUERY); }, 0);
|
2026-05-18 16:32:42 +08:00
|
|
|
|
return () => window.clearTimeout(timerId);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-20 23:34:08 +08:00
|
|
|
|
return () => { pipelineRunIdRef.current += 1; };
|
2026-05-18 16:32:42 +08:00
|
|
|
|
}, []);
|
2026-05-14 15:07:34 +08:00
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const parsingDocs = docs.filter(
|
|
|
|
|
|
(doc) => doc.status === 'parsing' && doc.docId && !doc.docId.startsWith('pending-')
|
|
|
|
|
|
);
|
|
|
|
|
|
if (parsingDocs.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
const timerId = window.setInterval(() => {
|
|
|
|
|
|
parsingDocs.forEach((doc) => {
|
|
|
|
|
|
void getDocumentStatus(doc.docId!).then((res) => {
|
|
|
|
|
|
if (res.status === 'indexed' || res.status === 'failed') {
|
|
|
|
|
|
setDocs((prev) =>
|
|
|
|
|
|
prev.map((d) =>
|
|
|
|
|
|
d.docId === doc.docId
|
|
|
|
|
|
? {
|
|
|
|
|
|
...d,
|
|
|
|
|
|
status: res.status === 'indexed' ? 'indexed' : 'failed',
|
|
|
|
|
|
chunks: res.num_chunks ?? d.chunks,
|
|
|
|
|
|
summary: res.summary ?? d.summary,
|
|
|
|
|
|
regulationType: res.regulation_type ?? d.regulationType,
|
|
|
|
|
|
version: res.version ?? d.version,
|
|
|
|
|
|
}
|
|
|
|
|
|
: d
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
|
});
|
|
|
|
|
|
}, 5000);
|
2026-05-14 15:07:34 +08:00
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
return () => window.clearInterval(timerId);
|
|
|
|
|
|
}, [docs]);
|
|
|
|
|
|
|
|
|
|
|
|
const runPipelineFlow = async (runId: number, uploadPromise: Promise<Awaited<ReturnType<typeof uploadDocument>>>) => {
|
|
|
|
|
|
const guard = (fn: () => void) => { if (pipelineRunIdRef.current !== runId) return false; fn(); return true; };
|
2026-05-14 15:07:34 +08:00
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
for (let i = 0; i < PIPELINE_STEPS.length - 1; i++) {
|
|
|
|
|
|
if (!guard(() => setActiveStep(i))) return;
|
2026-05-14 15:07:34 +08:00
|
|
|
|
await wait(STEP_DURATION_MS);
|
2026-05-20 23:34:08 +08:00
|
|
|
|
if (!guard(() => setCompletedSteps((p) => p.includes(i) ? p : [...p, i]))) return;
|
2026-05-14 15:07:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
if (!guard(() => setActiveStep(PIPELINE_STEPS.length - 1))) return;
|
2026-05-14 15:07:34 +08:00
|
|
|
|
await uploadPromise;
|
2026-05-20 23:34:08 +08:00
|
|
|
|
if (!guard(() => setCompletedSteps((p) => { const last = PIPELINE_STEPS.length - 1; return p.includes(last) ? p : [...p, last]; }))) return;
|
2026-05-14 15:07:34 +08:00
|
|
|
|
|
|
|
|
|
|
await wait(240);
|
|
|
|
|
|
if (pipelineRunIdRef.current !== runId) return;
|
|
|
|
|
|
setActiveStep(-1);
|
|
|
|
|
|
setPipelineStatus('completed');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
const uploadSingleFile = async (file: File, runId: number) => {
|
2026-05-14 15:07:34 +08:00
|
|
|
|
setUploading(true);
|
|
|
|
|
|
setUploadFileName(file.name);
|
|
|
|
|
|
setActiveStep(-1);
|
|
|
|
|
|
setCompletedSteps([]);
|
|
|
|
|
|
setPipelineStatus('running');
|
|
|
|
|
|
|
|
|
|
|
|
const fileSizeMB = (file.size / (1024 * 1024)).toFixed(1);
|
|
|
|
|
|
const tempDocId = `pending-${Date.now()}`;
|
|
|
|
|
|
const newDoc: Doc = {
|
|
|
|
|
|
id: Date.now(),
|
|
|
|
|
|
name: file.name,
|
|
|
|
|
|
chunks: 0,
|
|
|
|
|
|
size: `${fileSizeMB}MB`,
|
|
|
|
|
|
status: 'parsing',
|
|
|
|
|
|
docId: tempDocId,
|
2026-05-20 23:34:08 +08:00
|
|
|
|
regulationType: regulationType || undefined,
|
|
|
|
|
|
version: version || undefined,
|
2026-05-14 15:07:34 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
setDocs((prev) => [newDoc, ...prev]);
|
|
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
const uploadPromise = uploadDocument(file, {
|
|
|
|
|
|
regulationType: regulationType || undefined,
|
|
|
|
|
|
version: version || undefined,
|
|
|
|
|
|
});
|
2026-05-14 15:07:34 +08:00
|
|
|
|
void runPipelineFlow(runId, uploadPromise);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const uploadRes = await uploadPromise;
|
|
|
|
|
|
if (pipelineRunIdRef.current !== runId) return;
|
|
|
|
|
|
|
|
|
|
|
|
setDocs((prev) =>
|
|
|
|
|
|
prev.map((doc) =>
|
|
|
|
|
|
doc.id === newDoc.id
|
2026-05-20 23:34:08 +08:00
|
|
|
|
? { ...doc, status: 'indexed', docId: uploadRes.doc_id, chunks: uploadRes.num_chunks || doc.chunks, summary: uploadRes.summary }
|
2026-05-14 15:07:34 +08:00
|
|
|
|
: doc
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
void loadDocuments();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Upload failed:', error);
|
|
|
|
|
|
if (pipelineRunIdRef.current !== runId) return;
|
|
|
|
|
|
setDocs((prev) => prev.filter((doc) => doc.id !== newDoc.id));
|
|
|
|
|
|
setPipelineStatus('error');
|
|
|
|
|
|
setActiveStep(-1);
|
|
|
|
|
|
setCompletedSteps([]);
|
|
|
|
|
|
} finally {
|
2026-05-20 23:34:08 +08:00
|
|
|
|
setUploading(false);
|
|
|
|
|
|
setUploadFileName('');
|
|
|
|
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
|
|
|
|
|
|
|
|
|
|
// Process next file in batch queue
|
|
|
|
|
|
const next = batchQueueRef.current.shift();
|
|
|
|
|
|
if (next) {
|
|
|
|
|
|
const nextRunId = pipelineRunIdRef.current + 1;
|
|
|
|
|
|
pipelineRunIdRef.current = nextRunId;
|
|
|
|
|
|
void uploadSingleFile(next, nextRunId);
|
2026-05-14 15:07:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
|
const files = Array.from(event.target.files ?? []);
|
|
|
|
|
|
if (files.length === 0 || uploading) return;
|
|
|
|
|
|
|
|
|
|
|
|
const [first, ...rest] = files;
|
|
|
|
|
|
batchQueueRef.current = rest;
|
|
|
|
|
|
|
|
|
|
|
|
const runId = pipelineRunIdRef.current + 1;
|
|
|
|
|
|
pipelineRunIdRef.current = runId;
|
|
|
|
|
|
await uploadSingleFile(first, runId);
|
2026-05-14 15:07:34 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
const handleDelete = async (docId: string) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deleteDocument(docId);
|
|
|
|
|
|
setDocs((prev) => prev.filter((doc) => doc.docId !== docId));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Delete failed:', error);
|
|
|
|
|
|
}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
const handleRetry = async (docId: string) => {
|
|
|
|
|
|
setDocs((prev) => prev.map((doc) => doc.docId === docId ? { ...doc, status: 'parsing' } : doc));
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await retryDocument(docId);
|
|
|
|
|
|
setDocs((prev) =>
|
|
|
|
|
|
prev.map((doc) => doc.docId === docId ? { ...doc, status: 'indexed', chunks: result.num_chunks || doc.chunks } : doc)
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Retry failed:', error);
|
|
|
|
|
|
setDocs((prev) => prev.map((doc) => doc.docId === docId ? { ...doc, status: 'failed' } : doc));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const triggerFileUpload = () => { if (uploading) return; fileInputRef.current?.click(); };
|
|
|
|
|
|
|
|
|
|
|
|
const handleDragOver = (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); };
|
|
|
|
|
|
|
2026-05-14 15:07:34 +08:00
|
|
|
|
const handleDrop = (event: React.DragEvent) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
2026-05-20 23:34:08 +08:00
|
|
|
|
const files = Array.from(event.dataTransfer.files);
|
2026-05-14 15:07:34 +08:00
|
|
|
|
if (files.length === 0 || uploading) return;
|
|
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
const [first, ...rest] = files;
|
|
|
|
|
|
batchQueueRef.current = rest;
|
|
|
|
|
|
const runId = pipelineRunIdRef.current + 1;
|
|
|
|
|
|
pipelineRunIdRef.current = runId;
|
|
|
|
|
|
void uploadSingleFile(first, runId);
|
2026-05-14 15:07:34 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getStepStyle = (index: number) => {
|
2026-05-20 23:34:08 +08:00
|
|
|
|
if (activeStep === index) return { background: theme.bgCard, border: `2px solid ${theme.accent}`, boxShadow: `0 0 12px ${theme.accent}40` };
|
|
|
|
|
|
if (completedSteps.includes(index)) return { background: theme.bgCard, border: `1px solid ${theme.green}` };
|
|
|
|
|
|
return { background: theme.bgCard, border: `1px solid ${theme.border}` };
|
2026-05-14 15:07:34 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getCheckStyle = (index: number) => {
|
2026-05-20 23:34:08 +08:00
|
|
|
|
if (activeStep === index) return { background: theme.gradientAccent, color: '#fff', animation: 'pulse 0.6s infinite' };
|
|
|
|
|
|
if (completedSteps.includes(index)) return { background: theme.green, color: '#fff' };
|
|
|
|
|
|
return { background: theme.bgHover, color: theme.text3 };
|
2026-05-14 15:07:34 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getPipelineHint = () => {
|
|
|
|
|
|
if (pipelineStatus === 'running') {
|
2026-05-20 23:34:08 +08:00
|
|
|
|
const queueLen = batchQueueRef.current.length;
|
|
|
|
|
|
const suffix = queueLen > 0 ? ` (+${queueLen} 待上传)` : '';
|
|
|
|
|
|
return `${activeStep >= 0 ? PIPELINE_STEPS[activeStep].name : 'LOAD'} · ${uploadFileName}${suffix}`;
|
2026-05-14 15:07:34 +08:00
|
|
|
|
}
|
2026-05-20 23:34:08 +08:00
|
|
|
|
if (pipelineStatus === 'completed') return 'PIPELINE COMPLETE';
|
|
|
|
|
|
if (pipelineStatus === 'error') return 'PIPELINE FAILED';
|
2026-05-14 15:07:34 +08:00
|
|
|
|
return 'WAITING FOR UPLOAD';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
|
|
|
|
padding: '8px 12px',
|
|
|
|
|
|
fontSize: 13,
|
|
|
|
|
|
background: theme.bgCard,
|
|
|
|
|
|
border: `1px solid ${theme.border}`,
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
color: theme.text,
|
|
|
|
|
|
outline: 'none',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-14 15:07:34 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<Content>
|
|
|
|
|
|
<TPattern />
|
|
|
|
|
|
|
|
|
|
|
|
<section style={{ marginBottom: 56 }}>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
2026-05-14 15:07:34 +08:00
|
|
|
|
UPLOAD
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept=".pdf,.docx,.doc"
|
2026-05-20 23:34:08 +08:00
|
|
|
|
multiple
|
2026-05-14 15:07:34 +08:00
|
|
|
|
onChange={handleFileSelect}
|
|
|
|
|
|
style={{ display: 'none' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
{/* Metadata row */}
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={regulationType}
|
|
|
|
|
|
onChange={(e) => setRegulationType(e.target.value)}
|
|
|
|
|
|
style={{ ...inputStyle, flex: 1 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{REGULATION_TYPES.map((t) => (
|
|
|
|
|
|
<option key={t} value={t}>{t || '法规类型(可选)'}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={version}
|
|
|
|
|
|
onChange={(e) => setVersion(e.target.value)}
|
|
|
|
|
|
placeholder="版本号(可选,如 2024)"
|
|
|
|
|
|
style={{ ...inputStyle, flex: 1 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-14 15:07:34 +08:00
|
|
|
|
<div
|
|
|
|
|
|
onClick={triggerFileUpload}
|
|
|
|
|
|
onDragOver={handleDragOver}
|
|
|
|
|
|
onDrop={handleDrop}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
border: `2px solid ${uploading ? theme.accent : theme.border}`,
|
|
|
|
|
|
borderRadius: 16,
|
|
|
|
|
|
padding: 64,
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
background: theme.bgCard,
|
|
|
|
|
|
transition: 'all 0.3s ease',
|
|
|
|
|
|
cursor: uploading ? 'wait' : 'pointer',
|
|
|
|
|
|
boxShadow: !isDark ? '0 4px 16px rgba(226,0,116,0.08)' : 'none',
|
|
|
|
|
|
opacity: uploading ? 0.78 : 1,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<div style={{ width: 80, height: 80, borderRadius: 20, background: theme.bgHover, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
|
2026-05-14 15:07:34 +08:00
|
|
|
|
{uploading ? (
|
|
|
|
|
|
<div style={{ animation: 'spin 1s linear infinite' }}>
|
|
|
|
|
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none">
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<circle cx="12" cy="12" r="10" stroke={theme.accent} strokeWidth="2" strokeDasharray="60" strokeDashoffset="20" />
|
2026-05-14 15:07:34 +08:00
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none">
|
|
|
|
|
|
<path d="M12 4L12 16M12 4L7 9M12 4L17 9" stroke={theme.accent} strokeWidth="2" strokeLinecap="round" />
|
|
|
|
|
|
<path d="M4 18H20" stroke={theme.accent} strokeWidth="2" strokeLinecap="round" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: 16, fontWeight: 500, marginBottom: 8 }}>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
{uploading ? '正在上传并启动处理链路...' : '拖拽文件或点击上传(支持多选)'}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mono" style={{ fontSize: 12, color: theme.text3 }}>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
{uploading ? uploadFileName : 'PDF · DOCX · DOC · MAX 100MB · 支持批量'}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section style={{ marginBottom: 40 }}>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
2026-05-14 15:07:34 +08:00
|
|
|
|
PROCESSING PIPELINE
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="mono"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
fontSize: 11,
|
2026-05-20 23:34:08 +08:00
|
|
|
|
color: pipelineStatus === 'error' ? '#d64545' : pipelineStatus === 'completed' ? theme.green : theme.text3,
|
2026-05-14 15:07:34 +08:00
|
|
|
|
letterSpacing: '1px',
|
|
|
|
|
|
marginBottom: 12,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{getPipelineHint()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 16 }}>
|
|
|
|
|
|
{PIPELINE_STEPS.map((step, index) => {
|
|
|
|
|
|
const isCompleted = completedSteps.includes(index);
|
|
|
|
|
|
const isActive = activeStep === index;
|
2026-05-20 23:34:08 +08:00
|
|
|
|
const arrowActive = activeStep > index || isCompleted;
|
2026-05-14 15:07:34 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={step.name}
|
2026-05-20 23:34:08 +08:00
|
|
|
|
style={{ flex: 1, padding: 20, textAlign: 'center', borderRadius: 12, position: 'relative', boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none', transition: 'all 0.3s ease', ...getStepStyle(index) }}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<div style={{ width: 36, height: 36, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 12px', fontSize: 16, transition: 'all 0.3s ease', ...getCheckStyle(index) }}>
|
2026-05-14 15:07:34 +08:00
|
|
|
|
{isActive ? step.name : isCompleted ? '✓' : step.name}
|
|
|
|
|
|
</div>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<div className="mono" style={{ fontSize: 12, fontWeight: 600 }}>{step.name}</div>
|
2026-05-14 15:07:34 +08:00
|
|
|
|
<div className="mono" style={{ fontSize: 10, color: theme.text3, marginTop: 8 }}>
|
|
|
|
|
|
{isCompleted ? 'DONE' : isActive ? 'RUNNING' : 'PENDING'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{index < PIPELINE_STEPS.length - 1 && (
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<div style={{ position: 'absolute', right: -8, top: '50%', transform: 'translateY(-50%)', color: arrowActive ? theme.green : theme.borderLight, fontWeight: arrowActive ? 700 : 400, opacity: arrowActive ? 1 : 0.45, transition: 'all 0.3s ease' }}>
|
2026-05-14 15:07:34 +08:00
|
|
|
|
→
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginBottom: 56 }}>
|
|
|
|
|
|
<div>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
2026-05-14 15:07:34 +08:00
|
|
|
|
文档管理清单 ({loading ? '...' : docs.length})
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
|
|
|
|
{docs.map((doc) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={doc.id}
|
2026-05-20 23:34:08 +08:00
|
|
|
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 20, background: theme.bgCard, borderRadius: 12, border: `1px solid ${doc.status === 'parsing' ? theme.accent : theme.border}`, transition: 'all 0.2s ease', boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none' }}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
|
|
|
|
|
<div style={{ width: 44, height: 44, borderRadius: 10, background: theme.bgHover, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
2026-05-14 15:07:34 +08:00
|
|
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<path d="M14 2H6C5 2 4 3 4 4V20C4 21 5 22 6 22H18C19 22 20 21 20 20V8L14 2Z" stroke={theme.accent} strokeWidth="1.5" />
|
2026-05-14 15:07:34 +08:00
|
|
|
|
<path d="M14 2V8H20" stroke={theme.accent} strokeWidth="1.5" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div style={{ fontSize: 15, fontWeight: 500 }}>{doc.name}</div>
|
|
|
|
|
|
<div className="mono" style={{ fontSize: 11, color: theme.text3 }}>
|
2026-05-18 16:32:42 +08:00
|
|
|
|
{doc.updatedAt ? new Date(doc.updatedAt).toLocaleString() : doc.size}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
{doc.docId ? ` · ${doc.docId}` : ''}
|
|
|
|
|
|
</div>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
{/* Tags row */}
|
|
|
|
|
|
{(doc.regulationType || doc.version) && (
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 6, marginTop: 5, flexWrap: 'wrap' }}>
|
|
|
|
|
|
{doc.regulationType && (
|
|
|
|
|
|
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 4, background: `${theme.accent}18`, color: theme.accent, fontWeight: 500 }}>
|
|
|
|
|
|
{doc.regulationType}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{doc.version && (
|
|
|
|
|
|
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 4, background: theme.bgHover, color: theme.text2 }}>
|
|
|
|
|
|
v{doc.version}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{doc.summary && (
|
|
|
|
|
|
<div style={{ fontSize: 12, color: theme.text2, marginTop: 6, lineHeight: 1.5, maxWidth: 320, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
|
|
|
|
|
{doc.summary}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, flexShrink: 0 }}>
|
|
|
|
|
|
{doc.status === 'failed' && doc.docId && !doc.docId.startsWith('pending-') && (
|
|
|
|
|
|
<button onClick={() => void handleRetry(doc.docId!)} style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', border: `1px solid ${theme.accent}`, borderRadius: 6, color: theme.accent, cursor: 'pointer' }}>
|
|
|
|
|
|
重试
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{doc.downloadUrl && doc.status === 'indexed' && (
|
|
|
|
|
|
<a href={doc.downloadUrl} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: theme.accent, textDecoration: 'none' }}>
|
2026-05-14 15:07:34 +08:00
|
|
|
|
下载
|
|
|
|
|
|
</a>
|
|
|
|
|
|
)}
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<div className="mono" style={{ fontSize: 12, padding: '6px 12px', background: theme.bgHover, borderRadius: 6, color: doc.status === 'failed' ? '#d64545' : theme.text2 }}>
|
|
|
|
|
|
{doc.status === 'parsing' ? '处理中...' : doc.status === 'failed' ? '处理失败' : `${doc.chunks} chunks`}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
</div>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
{doc.docId && !doc.docId.startsWith('pending-') && (
|
|
|
|
|
|
<button onClick={() => void handleDelete(doc.docId!)} style={{ fontSize: 12, padding: '6px 10px', background: 'transparent', border: `1px solid ${theme.border}`, borderRadius: 6, color: theme.text3, cursor: 'pointer' }}>
|
|
|
|
|
|
删除
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
2026-05-14 15:07:34 +08:00
|
|
|
|
文档管理内法规检索
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
|
onChange={(event) => setSearchQuery(event.target.value)}
|
2026-05-20 23:34:08 +08:00
|
|
|
|
onKeyDown={(event) => { if (event.key === 'Enter') void runSearch(searchQuery); }}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
placeholder="输入法规关键词、条款或制度主题"
|
2026-05-20 23:34:08 +08:00
|
|
|
|
style={{ flex: 1, padding: 12, fontSize: 14, background: theme.bgCard, border: `1px solid ${theme.border}`, borderRadius: 8, color: theme.text, outline: 'none' }}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => void runSearch(searchQuery)}
|
|
|
|
|
|
disabled={searchLoading || !searchQuery.trim()}
|
2026-05-20 23:34:08 +08:00
|
|
|
|
style={{ padding: '12px 18px', fontSize: 13, fontWeight: 600, background: searchLoading || !searchQuery.trim() ? theme.bgHover : theme.gradientAccent, color: searchLoading || !searchQuery.trim() ? theme.text3 : '#fff', border: 'none', borderRadius: 8, cursor: searchLoading || !searchQuery.trim() ? 'not-allowed' : 'pointer' }}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
>
|
|
|
|
|
|
检索
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-20 23:34:08 +08:00
|
|
|
|
{searchError && <div style={{ marginBottom: 12, fontSize: 13, color: '#d64545' }}>{searchError}</div>}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
|
|
|
|
{searchResults.map((item) => (
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<div key={`${item.id}-${item.file}`} style={{ padding: 18, background: theme.bgCard, borderRadius: 12, border: `1px solid ${theme.border}`, boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none' }}>
|
2026-05-14 15:07:34 +08:00
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 6 }}>
|
|
|
|
|
|
<div style={{ fontSize: 14, fontWeight: 600, color: theme.text }}>{item.file}</div>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<div className="mono" style={{ fontSize: 11, color: theme.accent }}>{(item.score * 100).toFixed(1)}%</div>
|
2026-05-14 15:07:34 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mono" style={{ fontSize: 11, color: theme.text3, marginBottom: 8 }}>
|
2026-05-20 23:34:08 +08:00
|
|
|
|
{item.clause}{item.tags.length > 0 ? ` · ${item.tags.join(' · ')}` : ''}
|
2026-05-14 15:07:34 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: 12, color: theme.text2, lineHeight: 1.6 }}>{item.content}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
{!searchLoading && searchResults.length === 0 && (
|
2026-05-20 23:34:08 +08:00
|
|
|
|
<div style={{ padding: 24, borderRadius: 12, background: theme.bgCard, border: `1px solid ${theme.border}`, textAlign: 'center', color: theme.text3 }}>
|
2026-05-14 15:07:34 +08:00
|
|
|
|
暂无检索结果
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</Content>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|