feat(pkb): implement complete batch processing workflow and frontend optimization
- Frontend V3 architecture migration to modules/pkb - Implement three work modes: full-text reading, deep reading, batch processing - Complete batch processing: template selection, progress display, result export (CSV) - Integrate Ant Design X Chat component with streaming support - Add document upload modal with drag-and-drop support - Optimize UI: multi-line table display, citation formatting, auto-scroll - Fix 10+ technical issues: API mapping, state sync, form clearing - Update documentation: development records and module status Performance: 3 docs batch processing ~17-28s Status: PKB module now production-ready (90% complete)
This commit is contained in:
@@ -554,5 +554,6 @@ export default FulltextDetailDrawer;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -147,5 +147,6 @@ export const useAssets = (activeTab: AssetTabType) => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -137,5 +137,6 @@ export const useRecentTasks = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -336,5 +336,6 @@ export default DropnaDialog;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -421,5 +421,6 @@ export default MetricTimePanel;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -307,5 +307,6 @@ export default PivotPanel;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -107,5 +107,6 @@ export function useSessionStatus({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -99,5 +99,6 @@ export interface DataStats {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -95,5 +95,6 @@ export type AssetTabType = 'all' | 'processed' | 'raw';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -216,3 +216,4 @@ export const documentSelectionApi = {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Upload, message, Progress, Card } from 'antd';
|
||||
import { InboxOutlined } from '@ant-design/icons';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { useKnowledgeBaseStore } from '../../stores/useKnowledgeBaseStore';
|
||||
import { useKnowledgeBaseStore } from '../stores/useKnowledgeBaseStore';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button, Table, Progress, Alert, message, Card, Steps, Checkbox, Radio } from 'antd';
|
||||
import { Button, Table, Progress, message, Card, Steps, Checkbox, Radio, Tooltip } from 'antd';
|
||||
import { Play, Download, RotateCw, FileText, CheckCircle2, Zap } from 'lucide-react';
|
||||
import type { KnowledgeBase, Document } from '../../api/knowledgeBaseApi';
|
||||
|
||||
@@ -31,42 +31,21 @@ interface BatchResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 模板配置(ID必须与后端 PRESET_TEMPLATES 匹配)
|
||||
const TEMPLATES: BatchTemplate[] = [
|
||||
{
|
||||
id: 'clinicalResearch',
|
||||
id: 'clinical_research', // ✅ 必须与后端模板ID匹配
|
||||
name: '临床研究信息提取',
|
||||
description: '提取研究目的、方法、样本量、结论等核心信息',
|
||||
description: '提取研究目的、设计、对象、样本量、干预、对照、结果、证据等级',
|
||||
fields: [
|
||||
{ key: 'title', label: '研究标题' },
|
||||
{ key: 'purpose', label: '研究目的' },
|
||||
{ key: 'method', label: '研究方法' },
|
||||
{ key: 'sampleSize', label: '样本量' },
|
||||
{ key: 'intervention', label: '干预措施' },
|
||||
{ key: 'outcome', label: '主要结局' },
|
||||
{ key: 'conclusion', label: '研究结论' },
|
||||
{ key: 'limitation', label: '研究局限' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'drug_safety',
|
||||
name: '药物安全性分析',
|
||||
description: '提取药物不良反应、禁忌症、注意事项等',
|
||||
fields: [
|
||||
{ key: 'drugName', label: '药物名称' },
|
||||
{ key: 'adverseReactions', label: '不良反应' },
|
||||
{ key: 'contraindications', label: '禁忌症' },
|
||||
{ key: 'warnings', label: '警告事项' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'patient_baseline',
|
||||
name: '患者基线特征',
|
||||
description: '提取患者年龄、性别、诊断、既往史等基线信息',
|
||||
fields: [
|
||||
{ key: 'age', label: '年龄' },
|
||||
{ key: 'gender', label: '性别' },
|
||||
{ key: 'diagnosis', label: '主要诊断' },
|
||||
{ key: 'comorbidities', label: '合并症' },
|
||||
{ key: 'research_purpose', label: '研究目的' },
|
||||
{ key: 'research_design', label: '研究设计' },
|
||||
{ key: 'research_subjects', label: '研究对象' },
|
||||
{ key: 'sample_size', label: '样本量' },
|
||||
{ key: 'intervention_group', label: '干预组' },
|
||||
{ key: 'control_group', label: '对照组' },
|
||||
{ key: 'results_data', label: '结果及数据' },
|
||||
{ key: 'oxford_level', label: '牛津评级' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -80,9 +59,19 @@ export const BatchModeComplete: React.FC<BatchModeCompleteProps> = ({
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<BatchTemplate | null>(TEMPLATES[0]);
|
||||
const [selectedDocs, setSelectedDocs] = useState<string[]>([]);
|
||||
const [results, setResults] = useState<BatchResult[]>([]);
|
||||
const [, setIsExecuting] = useState(false);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
|
||||
const completedDocs = documents.filter(doc => doc.status === 'completed');
|
||||
// 过滤出已完成解析的文档,并去重(确保唯一性)
|
||||
const completedDocs = React.useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return documents.filter(doc => {
|
||||
if (doc.status === 'completed' && !seen.has(doc.id)) {
|
||||
seen.add(doc.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [documents]);
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化模板(如果有传入则使用,否则默认第一个)
|
||||
@@ -92,16 +81,18 @@ export const BatchModeComplete: React.FC<BatchModeCompleteProps> = ({
|
||||
}
|
||||
}, [initialTemplate]);
|
||||
|
||||
// 处理文档选择
|
||||
const handleDocSelect = (docId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
// 切换文档选择状态(toggle模式,参考旧版实现)
|
||||
const handleToggleDocument = (docId: string) => {
|
||||
if (selectedDocs.includes(docId)) {
|
||||
// 已选中 -> 取消选择
|
||||
setSelectedDocs(prev => prev.filter(id => id !== docId));
|
||||
} else {
|
||||
// 未选中 -> 添加选择
|
||||
if (selectedDocs.length >= 50) {
|
||||
message.warning('最多选择50篇文档');
|
||||
return;
|
||||
}
|
||||
setSelectedDocs(prev => [...prev, docId]);
|
||||
} else {
|
||||
setSelectedDocs(prev => prev.filter(id => id !== docId));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,11 +121,11 @@ export const BatchModeComplete: React.FC<BatchModeCompleteProps> = ({
|
||||
setStep(1);
|
||||
setIsExecuting(true);
|
||||
|
||||
// 初始化结果
|
||||
const initialResults: BatchResult[] = selectedDocs.map(docId => {
|
||||
const doc = documents.find(d => d.id === docId);
|
||||
// 初始化结果 - 使用唯一索引确保key唯一
|
||||
const initialResults: BatchResult[] = selectedDocs.map((docId, index) => {
|
||||
const doc = completedDocs.find(d => d.id === docId);
|
||||
return {
|
||||
documentId: docId,
|
||||
documentId: `${docId}-${index}`, // 确保唯一性
|
||||
documentName: doc?.filename || '未知文档',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
@@ -143,81 +134,185 @@ export const BatchModeComplete: React.FC<BatchModeCompleteProps> = ({
|
||||
setResults(initialResults);
|
||||
|
||||
try {
|
||||
// 调用批处理API
|
||||
// 调用批处理API(使用v2新版API)
|
||||
// 完整路径: /api/v2/pkb/batch-tasks/batch/execute
|
||||
// 请求体格式必须匹配后端 ExecuteBatchBody 接口
|
||||
const response = await fetch('/api/v2/pkb/batch-tasks/batch/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kb_id: kbId,
|
||||
template_id: selectedTemplate.id,
|
||||
document_ids: selectedDocs,
|
||||
kb_id: kbId, // 后端期望 kb_id
|
||||
document_ids: selectedDocs, // 后端期望 document_ids
|
||||
template_type: 'preset', // 使用预设模板
|
||||
template_id: selectedTemplate.id, // 后端期望 template_id
|
||||
model_type: 'qwen-long', // 使用qwen-long模型
|
||||
task_name: `${selectedTemplate.name}_${new Date().toLocaleString('zh-CN')}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('批处理执行失败');
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || '批处理执行失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const taskId = data.task_id;
|
||||
const responseData = await response.json();
|
||||
console.log('[BatchMode] API响应:', responseData);
|
||||
|
||||
// 后端返回格式: { success: true, data: { task_id: xxx } }
|
||||
const taskId = responseData.data?.task_id || responseData.taskId || responseData.task_id;
|
||||
|
||||
if (!taskId) {
|
||||
console.error('[BatchMode] 未获取到taskId:', responseData);
|
||||
message.error('批处理任务创建失败:未获取到任务ID');
|
||||
setIsExecuting(false);
|
||||
setStep(0);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[BatchMode] 任务已创建,开始轮询:', taskId);
|
||||
|
||||
// 轮询任务状态
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const statusRes = await fetch(`/api/v2/pkb/batch-tasks/${taskId}`);
|
||||
const statusData = await statusRes.json();
|
||||
const statusRes = await fetch(`/api/v2/pkb/batch-tasks/batch/tasks/${taskId}`);
|
||||
if (!statusRes.ok) {
|
||||
console.error('[BatchMode] 获取任务状态失败:', statusRes.status);
|
||||
return;
|
||||
}
|
||||
const statusJson = await statusRes.json();
|
||||
const taskData = statusJson.data || statusJson;
|
||||
|
||||
console.log('[BatchMode] 任务状态:', taskData);
|
||||
|
||||
// 更新进度
|
||||
// 计算进度 - 基于后端返回的completed_count
|
||||
const completedCount = taskData.completed_count || 0;
|
||||
const failedCount = taskData.failed_count || 0;
|
||||
const processedCount = completedCount + failedCount;
|
||||
|
||||
// 更新每个文档的状态(根据处理进度模拟)
|
||||
setResults(prev => prev.map((r, idx) => {
|
||||
const docResult = statusData.results?.[idx];
|
||||
if (docResult) {
|
||||
return {
|
||||
...r,
|
||||
status: docResult.status,
|
||||
progress: docResult.progress || 0,
|
||||
result: docResult.result,
|
||||
error: docResult.error,
|
||||
};
|
||||
if (idx < completedCount) {
|
||||
// 已完成
|
||||
return { ...r, status: 'completed' as const, progress: 100 };
|
||||
} else if (idx < processedCount) {
|
||||
// 失败
|
||||
return { ...r, status: 'error' as const, progress: 100 };
|
||||
} else if (idx === processedCount && taskData.status === 'processing') {
|
||||
// 正在处理
|
||||
return { ...r, status: 'processing' as const, progress: 50 };
|
||||
}
|
||||
return r;
|
||||
}));
|
||||
|
||||
// 检查是否全部完成
|
||||
if (statusData.status === 'completed' || statusData.status === 'failed') {
|
||||
if (taskData.status === 'completed' || taskData.status === 'failed') {
|
||||
clearInterval(pollInterval);
|
||||
setIsExecuting(false);
|
||||
setStep(2);
|
||||
|
||||
if (statusData.status === 'completed') {
|
||||
message.success('批处理完成!');
|
||||
// 获取最终结果
|
||||
try {
|
||||
const resultsRes = await fetch(`/api/v2/pkb/batch-tasks/batch/tasks/${taskId}/results`);
|
||||
console.log('[BatchMode] 获取结果响应状态:', resultsRes.status);
|
||||
|
||||
if (resultsRes.ok) {
|
||||
const resultsJson = await resultsRes.json();
|
||||
console.log('[BatchMode] 结果数据:', JSON.stringify(resultsJson, null, 2));
|
||||
|
||||
const resultsData = resultsJson.data?.results || [];
|
||||
console.log('[BatchMode] 解析到的结果数量:', resultsData.length);
|
||||
|
||||
if (resultsData.length > 0) {
|
||||
// 构建新的结果数组 - 后端返回的提取数据在 data 字段中
|
||||
const newResults: BatchResult[] = resultsData.map((docResult: any, idx: number) => {
|
||||
console.log(`[BatchMode] 文档 ${idx}:`, {
|
||||
id: docResult.document_id,
|
||||
name: docResult.document_name,
|
||||
status: docResult.status,
|
||||
hasData: !!docResult.data,
|
||||
dataKeys: docResult.data ? Object.keys(docResult.data) : [],
|
||||
});
|
||||
|
||||
// 🔑 后端返回的状态是 "success" 或 "failed",需要映射为前端的 "completed" 或 "error"
|
||||
const isSuccess = docResult.status === 'success' || docResult.status === 'completed';
|
||||
|
||||
return {
|
||||
documentId: docResult.document_id || `doc-${idx}`,
|
||||
documentName: docResult.document_name || `文档${idx + 1}`,
|
||||
status: isSuccess ? 'completed' as const : 'error' as const,
|
||||
progress: 100,
|
||||
result: docResult.data, // 后端返回的提取数据
|
||||
error: docResult.error_message,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('[BatchMode] 更新结果:', newResults);
|
||||
setResults(newResults);
|
||||
} else {
|
||||
console.warn('[BatchMode] 没有结果数据');
|
||||
}
|
||||
} else {
|
||||
console.error('[BatchMode] 获取结果失败,状态码:', resultsRes.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BatchMode] 获取结果异常:', e);
|
||||
}
|
||||
|
||||
// 延迟设置step,确保状态更新完成
|
||||
setTimeout(() => {
|
||||
setStep(2);
|
||||
}, 100);
|
||||
|
||||
if (taskData.status === 'completed') {
|
||||
message.success(`批处理完成!成功 ${completedCount} 篇,失败 ${failedCount} 篇`);
|
||||
} else {
|
||||
message.error('批处理失败');
|
||||
message.error('批处理失败: ' + (taskData.error || '未知错误'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询任务状态失败:', error);
|
||||
console.error('[BatchMode] 轮询任务状态失败:', error);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// 设置超时保护(5分钟)
|
||||
setTimeout(() => {
|
||||
clearInterval(pollInterval);
|
||||
if (isExecuting) {
|
||||
setIsExecuting(false);
|
||||
setStep(2);
|
||||
message.warning('任务执行超时,请稍后查看结果');
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : '执行失败';
|
||||
message.error(errorMessage);
|
||||
setIsExecuting(false);
|
||||
setStep(0); // 返回配置步骤
|
||||
}
|
||||
};
|
||||
|
||||
// 导出Excel
|
||||
// 导出Excel (CSV格式)
|
||||
const handleExport = () => {
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
// 构建CSV数据
|
||||
const completedResults = results.filter(r => r.status === 'completed' && r.result);
|
||||
|
||||
if (completedResults.length === 0) {
|
||||
message.warning('没有可导出的结果');
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建CSV数据 - 使用模板定义的字段
|
||||
const headers = ['文档名称', ...selectedTemplate.fields.map(f => f.label)];
|
||||
const rows = results
|
||||
.filter(r => r.status === 'completed' && r.result)
|
||||
.map(r => [
|
||||
r.documentName,
|
||||
...selectedTemplate.fields.map(f => r.result?.[f.key] || '-'),
|
||||
]);
|
||||
const rows = completedResults.map(r => [
|
||||
r.documentName,
|
||||
...selectedTemplate.fields.map(f => {
|
||||
const value = r.result?.[f.key];
|
||||
if (!value) return '-';
|
||||
// 处理换行符和引号
|
||||
return String(value).replace(/"/g, '""').replace(/\n/g, ' ');
|
||||
}),
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
@@ -228,8 +323,11 @@ export const BatchModeComplete: React.FC<BatchModeCompleteProps> = ({
|
||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `批处理结果_${selectedTemplate.name}_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
link.download = `批处理结果_${selectedTemplate.name}_${timestamp}.csv`;
|
||||
link.click();
|
||||
|
||||
message.success(`已导出 ${completedResults.length} 条结果`);
|
||||
};
|
||||
|
||||
// 重置
|
||||
@@ -247,9 +345,9 @@ export const BatchModeComplete: React.FC<BatchModeCompleteProps> = ({
|
||||
current={step}
|
||||
size="small"
|
||||
items={[
|
||||
{ title: '配置任务', description: '选择模板和文档' },
|
||||
{ title: '执行中', description: '正在处理文档' },
|
||||
{ title: '查看结果', description: '导出数据' },
|
||||
{ title: '配置任务', subTitle: '选择模板和文档' },
|
||||
{ title: '执行中', subTitle: '正在处理文档' },
|
||||
{ title: '查看结果', subTitle: '导出数据' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -348,14 +446,12 @@ export const BatchModeComplete: React.FC<BatchModeCompleteProps> = ({
|
||||
? 'bg-blue-50 border border-blue-200'
|
||||
: 'hover:bg-gray-50 border border-transparent'
|
||||
}`}
|
||||
onClick={() => handleDocSelect(doc.id, !selectedDocs.includes(doc.id))}
|
||||
onClick={() => handleToggleDocument(doc.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedDocs.includes(doc.id)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDocSelect(doc.id, e.target.checked);
|
||||
}}
|
||||
onChange={() => handleToggleDocument(doc.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="w-8 h-8 bg-red-50 text-red-500 rounded flex items-center justify-center ml-3 mr-3 flex-shrink-0">
|
||||
<FileText className="w-4 h-4" />
|
||||
@@ -375,29 +471,32 @@ export const BatchModeComplete: React.FC<BatchModeCompleteProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<Alert
|
||||
message={
|
||||
<span className="text-sm">
|
||||
已选择 <strong className="text-blue-600">{selectedDocs.length}</strong> 篇文档
|
||||
{selectedDocs.length < 3 && <span className="text-orange-600 ml-2">(至少需要3篇)</span>}
|
||||
</span>
|
||||
}
|
||||
type={selectedDocs.length >= 3 ? 'success' : 'warning'}
|
||||
showIcon
|
||||
/>
|
||||
<div className={`flex items-center p-3 rounded-lg ${
|
||||
selectedDocs.length >= 3
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-yellow-50 border border-yellow-200'
|
||||
}`}>
|
||||
<CheckCircle2 className={`w-4 h-4 mr-2 ${
|
||||
selectedDocs.length >= 3 ? 'text-green-500' : 'text-yellow-500'
|
||||
}`} />
|
||||
<span className="text-sm">
|
||||
已选择 <strong className="text-blue-600">{selectedDocs.length}</strong> 篇文档
|
||||
{selectedDocs.length < 3 && <span className="text-orange-600 ml-2">(至少需要3篇)</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 开始按钮 */}
|
||||
{/* 开始按钮 - 显示实际选择的文档数量 */}
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<Play className="w-4 h-4" />}
|
||||
onClick={handleExecute}
|
||||
disabled={selectedDocs.length < 3 || !selectedTemplate}
|
||||
disabled={selectedDocs.length < 3 || selectedDocs.length > 50 || !selectedTemplate}
|
||||
className="w-full h-12 text-base font-medium shadow-lg"
|
||||
>
|
||||
开始批处理 ({selectedDocs.length} 篇文档)
|
||||
🚀 开始批处理 ({selectedDocs.length} 篇文档)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -471,37 +570,84 @@ export const BatchModeComplete: React.FC<BatchModeCompleteProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||
<Table
|
||||
dataSource={results.filter(r => r.status === 'completed')}
|
||||
rowKey="documentId"
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content', y: '100%' }}
|
||||
size="small"
|
||||
columns={[
|
||||
{
|
||||
title: '文档名称',
|
||||
dataIndex: 'documentName',
|
||||
key: 'documentName',
|
||||
fixed: 'left',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (text) => (
|
||||
<div className="flex items-center">
|
||||
<FileText className="w-4 h-4 text-red-400 mr-2 flex-shrink-0" />
|
||||
<span className="truncate">{text}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...selectedTemplate.fields.map(field => ({
|
||||
title: field.label,
|
||||
dataIndex: ['result', field.key],
|
||||
key: field.key,
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (text: string) => text || '-',
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
{results.filter(r => r.status === 'completed' && r.result).length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-slate-400">
|
||||
<div className="text-center">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>暂无提取结果</p>
|
||||
<p className="text-xs mt-1">请等待处理完成或检查是否有失败的文档</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
dataSource={results.filter(r => r.status === 'completed' && r.result)}
|
||||
rowKey="documentId"
|
||||
pagination={false}
|
||||
scroll={{ x: 1800, y: 'calc(100vh - 400px)' }}
|
||||
size="small"
|
||||
className="batch-results-table"
|
||||
columns={[
|
||||
{
|
||||
title: '文档名称',
|
||||
dataIndex: 'documentName',
|
||||
key: 'documentName',
|
||||
fixed: 'left',
|
||||
width: 220,
|
||||
render: (text: string) => (
|
||||
<Tooltip title={text} placement="topLeft">
|
||||
<div className="flex items-center">
|
||||
<FileText className="w-4 h-4 text-red-400 mr-2 flex-shrink-0" />
|
||||
<span
|
||||
className="font-medium text-slate-800"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
...selectedTemplate.fields.map(field => ({
|
||||
title: field.label,
|
||||
key: field.key,
|
||||
width: field.key === 'results_data' ? 280 : 180, // 结果数据列更宽
|
||||
render: (_: unknown, record: BatchResult) => {
|
||||
const value = record.result?.[field.key];
|
||||
if (!value) return <span className="text-slate-300">-</span>;
|
||||
|
||||
const textValue = String(value);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={<div style={{ maxWidth: 400, maxHeight: 300, overflow: 'auto' }}>{textValue}</div>}
|
||||
placement="topLeft"
|
||||
overlayStyle={{ maxWidth: 450 }}
|
||||
>
|
||||
<div
|
||||
className="text-slate-700 text-sm leading-relaxed cursor-help"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3, // 最多显示3行
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{textValue}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* 逐篇精读模式组件 - ChatGPT风格全屏聊天
|
||||
* 修复:参考文献格式、文档切换对话独立
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Empty } from 'antd';
|
||||
import { FileText } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { FileText, BookOpen, ExternalLink } from 'lucide-react';
|
||||
import { ChatContainer } from '@/shared/components/Chat';
|
||||
import type { KnowledgeBase, Document } from '../../api/knowledgeBaseApi';
|
||||
|
||||
@@ -14,10 +14,92 @@ interface DeepReadModeProps {
|
||||
selectedDocuments: Document[];
|
||||
}
|
||||
|
||||
// 消息渲染参数类型
|
||||
interface MessageRenderParams {
|
||||
id: string | number;
|
||||
message: {
|
||||
id: string | number;
|
||||
role: string;
|
||||
content: string;
|
||||
status?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
status: string;
|
||||
}
|
||||
|
||||
// 自定义消息渲染器 - 解析并格式化参考文献
|
||||
const renderMessageContent = (params: MessageRenderParams) => {
|
||||
// 从params中提取消息内容
|
||||
const textContent = params?.message?.content;
|
||||
|
||||
// 空内容处理
|
||||
if (!textContent || typeof textContent !== 'string' || textContent.trim() === '') {
|
||||
return <div className="text-slate-400 italic">加载中...</div>;
|
||||
}
|
||||
|
||||
// 处理参考文献格式
|
||||
let formattedContent = textContent;
|
||||
|
||||
// 1. 移除HTML标签,转换为可读格式
|
||||
formattedContent = formattedContent.replace(
|
||||
/<span[^>]*id="citation-detail-(\d+)"[^>]*>\[(\d+)\]<\/span>\s*\*?\*?([^*\n]+)\*?\*?/g,
|
||||
(_, _num, num2, title) => `\n📄 [${num2}] ${title.trim()}`
|
||||
);
|
||||
|
||||
// 2. 处理其他HTML span标签
|
||||
formattedContent = formattedContent.replace(/<span[^>]*>[^<]*<\/span>/g, '');
|
||||
|
||||
// 3. 处理Markdown加粗中的下划线(文件名)
|
||||
formattedContent = formattedContent.replace(
|
||||
/\*\*([^*]+\.pdf)\*\*/gi,
|
||||
'📄 **$1**'
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
{formattedContent.split('\n').map((line, idx) => {
|
||||
// 检测是否是参考文献行
|
||||
if (line.startsWith('📄')) {
|
||||
return (
|
||||
<div key={idx} className="flex items-start my-2 p-2 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<BookOpen className="w-4 h-4 text-blue-500 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-slate-700">{line.replace('📄 ', '')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检测是否是"参考文献"标题
|
||||
if (line.includes('**参考文献**') || line.includes('📚 **参考文献**')) {
|
||||
return (
|
||||
<div key={idx} className="font-semibold text-slate-800 mt-4 mb-2 flex items-center">
|
||||
<BookOpen className="w-4 h-4 mr-2 text-blue-600" />
|
||||
参考文献
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 普通文本
|
||||
return line ? <p key={idx} className="mb-2 text-slate-700 leading-relaxed">{line}</p> : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeepReadMode: React.FC<DeepReadModeProps> = ({
|
||||
kbId,
|
||||
selectedDocuments
|
||||
}) => {
|
||||
// 使用useMemo确保文档切换时生成新的key
|
||||
const conversationKey = useMemo(() => {
|
||||
if (!selectedDocuments || selectedDocuments.length === 0) return '';
|
||||
return `kb-deepread-${kbId}-${selectedDocuments[0].id}-${Date.now()}`;
|
||||
}, [kbId, selectedDocuments]);
|
||||
|
||||
const selectedDocIds = useMemo(() =>
|
||||
selectedDocuments.map(d => d.id),
|
||||
[selectedDocuments]
|
||||
);
|
||||
|
||||
if (!selectedDocuments || selectedDocuments.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-slate-50">
|
||||
@@ -35,15 +117,25 @@ export const DeepReadMode: React.FC<DeepReadModeProps> = ({
|
||||
}
|
||||
|
||||
const selectedDoc = selectedDocuments[0];
|
||||
const selectedDocIds = selectedDocuments.map(d => d.id);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
{/* Chat组件 - 全屏展开 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* 当前文档提示 */}
|
||||
<div className="flex-shrink-0 px-4 py-2 bg-purple-50 border-b border-purple-100">
|
||||
<div className="flex items-center text-sm">
|
||||
<FileText className="w-4 h-4 text-purple-500 mr-2" />
|
||||
<span className="text-purple-700">当前精读:</span>
|
||||
<span className="font-medium text-purple-900 ml-1 truncate max-w-md" title={selectedDoc.filename}>
|
||||
{selectedDoc.filename}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat组件 - 使用key强制重新渲染 */}
|
||||
<div className="flex-1 overflow-hidden" key={conversationKey}>
|
||||
<ChatContainer
|
||||
conversationType="pkb"
|
||||
conversationKey={`kb-deepread-${kbId}-${selectedDoc.id}`}
|
||||
conversationKey={conversationKey}
|
||||
defaultMessages={[{
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
@@ -54,6 +146,9 @@ export const DeepReadMode: React.FC<DeepReadModeProps> = ({
|
||||
providerConfig={{
|
||||
apiEndpoint: '/api/v1/chat/stream',
|
||||
requestFn: async (message: string) => {
|
||||
// 🔑 关键:传递 fullTextDocumentIds 而不是 documentIds
|
||||
// fullTextDocumentIds 会触发全文加载模式,AI可以看到完整文献
|
||||
// documentIds 只是过滤RAG检索结果,AI只能看到片段
|
||||
const response = await fetch('/api/v1/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -64,7 +159,7 @@ export const DeepReadMode: React.FC<DeepReadModeProps> = ({
|
||||
content: message,
|
||||
modelType: 'qwen-long',
|
||||
knowledgeBaseIds: [kbId],
|
||||
documentIds: selectedDocIds, // 🌟 关键参数:限定文档范围
|
||||
fullTextDocumentIds: selectedDocIds, // ✅ 改用全文模式
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -72,7 +167,6 @@ export const DeepReadMode: React.FC<DeepReadModeProps> = ({
|
||||
throw new Error(`API请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
// 处理流式响应
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = '';
|
||||
@@ -109,6 +203,7 @@ export const DeepReadMode: React.FC<DeepReadModeProps> = ({
|
||||
};
|
||||
},
|
||||
}}
|
||||
customMessageRenderer={renderMessageContent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { BookOpen, FileText } from 'lucide-react';
|
||||
import { ChatContainer } from '@/shared/components/Chat';
|
||||
import type { KnowledgeBase, Document } from '../../api/knowledgeBaseApi';
|
||||
|
||||
@@ -12,13 +13,87 @@ interface FullTextModeProps {
|
||||
documents: Document[];
|
||||
}
|
||||
|
||||
export const FullTextMode: React.FC<FullTextModeProps> = ({ kbId, documents }) => {
|
||||
// 准备全文文档ID
|
||||
const fullTextDocumentIds = documents
|
||||
.filter(doc => doc.status === 'completed')
|
||||
.map(doc => doc.id);
|
||||
// 消息渲染参数类型
|
||||
interface MessageRenderParams {
|
||||
id: string | number;
|
||||
message: {
|
||||
id: string | number;
|
||||
role: string;
|
||||
content: string;
|
||||
status?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
status: string;
|
||||
}
|
||||
|
||||
const totalTokens = documents.reduce((sum, d) => sum + (d.tokensCount || 0), 0);
|
||||
// 自定义消息渲染器 - 解析并格式化参考文献
|
||||
const renderMessageContent = (params: MessageRenderParams) => {
|
||||
const textContent = params?.message?.content;
|
||||
|
||||
if (!textContent || typeof textContent !== 'string' || textContent.trim() === '') {
|
||||
return <div className="text-slate-400 italic">加载中...</div>;
|
||||
}
|
||||
|
||||
// 处理参考文献格式
|
||||
let formattedContent = textContent;
|
||||
|
||||
// 1. 移除HTML标签,转换为可读格式
|
||||
// 格式: <span id="citation-detail-1">[1]</span> 📄 **文件名** - 第0段 (相关度100%)
|
||||
formattedContent = formattedContent.replace(
|
||||
/<span[^>]*id="citation-detail-(\d+)"[^>]*>\[(\d+)\]<\/span>\s*📄?\s*\*?\*?([^*\n-]+)\*?\*?\s*-\s*第(\d+)段\s*\(相关度(\d+)%\)/g,
|
||||
(_, _id, num, filename, position, score) =>
|
||||
`\n📖 [${num}] ${filename.trim()} - 第${position}段 (相关度${score}%)`
|
||||
);
|
||||
|
||||
// 2. 处理其他HTML span标签
|
||||
formattedContent = formattedContent.replace(/<span[^>]*>[^<]*<\/span>/g, '');
|
||||
|
||||
// 3. 清理多余的星号(Markdown加粗)
|
||||
formattedContent = formattedContent.replace(/\*\*/g, '');
|
||||
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
{formattedContent.split('\n').map((line, idx) => {
|
||||
// 检测是否是参考文献行
|
||||
if (line.startsWith('📖')) {
|
||||
return (
|
||||
<div key={idx} className="flex items-start my-2 p-3 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<BookOpen className="w-4 h-4 text-blue-500 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-slate-700">{line.replace('📖 ', '')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检测是否是"参考文献"标题
|
||||
if (line.includes('参考文献')) {
|
||||
return (
|
||||
<div key={idx} className="font-semibold text-slate-800 mt-6 mb-3 flex items-center border-b border-slate-200 pb-2">
|
||||
<FileText className="w-5 h-5 mr-2 text-blue-600" />
|
||||
📚 参考文献
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 分隔线
|
||||
if (line.trim() === '---') {
|
||||
return <hr key={idx} className="my-4 border-slate-200" />;
|
||||
}
|
||||
|
||||
// 普通文本
|
||||
return line.trim() ? <p key={idx} className="mb-2 text-slate-700 leading-relaxed">{line}</p> : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FullTextMode: React.FC<FullTextModeProps> = ({ kbId, documents }) => {
|
||||
// 准备全文文档ID(只选择已完成的文档)
|
||||
const completedDocs = documents.filter(doc => doc.status === 'completed');
|
||||
const fullTextDocumentIds = completedDocs.map(doc => doc.id);
|
||||
const totalTokens = completedDocs.reduce((sum, d) => sum + (d.tokensCount || 0), 0);
|
||||
|
||||
// 🔑 将文档数量加入key,确保文档加载完成后重新渲染ChatContainer
|
||||
const conversationKey = `kb-fulltext-${kbId}-${completedDocs.length}`;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
@@ -26,26 +101,15 @@ export const FullTextMode: React.FC<FullTextModeProps> = ({ kbId, documents }) =
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ChatContainer
|
||||
conversationType="pkb"
|
||||
conversationKey={`kb-fulltext-${kbId}`}
|
||||
conversationKey={conversationKey}
|
||||
defaultMessages={[{
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
content: `您好!我已加载全部 ${documents.length} 篇文档(共${totalTokens.toLocaleString()} tokens)。\n\n我可以帮您:\n- 📚 综合多篇文献进行对比分析\n- 🔍 查找特定主题的共识与争议\n- 📊 总结研究趋势和发展脉络\n\n请告诉我您想了解什么?`,
|
||||
content: `您好!我已加载全部 ${completedDocs.length} 篇文档(共${totalTokens.toLocaleString()} tokens)。\n\n我可以帮您:\n- 📚 综合多篇文献进行对比分析\n- 🔍 查找特定主题的共识与争议\n- 📊 总结研究趋势和发展脉络\n\n请告诉我您想了解什么?`,
|
||||
status: 'success',
|
||||
timestamp: Date.now(),
|
||||
}]}
|
||||
customMessageRenderer={(msgInfo) => {
|
||||
const msg = msgInfo.message;
|
||||
if (msg.role === 'assistant' && msg.content) {
|
||||
// 处理参考文献格式
|
||||
const processedContent = msg.content.replace(
|
||||
/\*\*参考文献\*\*/g,
|
||||
'\n\n**📚 参考文献**\n'
|
||||
);
|
||||
return <div className="whitespace-pre-wrap">{processedContent}</div>;
|
||||
}
|
||||
return <div className="whitespace-pre-wrap">{msg.content}</div>;
|
||||
}}
|
||||
customMessageRenderer={renderMessageContent}
|
||||
providerConfig={{
|
||||
apiEndpoint: '/api/v1/chat/stream',
|
||||
requestFn: async (message: string) => {
|
||||
|
||||
@@ -284,3 +284,4 @@ export default KnowledgePage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Table, Button, message, Progress, Dropdown } from 'antd';
|
||||
import { Table, Button, message, Progress, Dropdown, Modal } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import DocumentUpload from '../components/DocumentUpload';
|
||||
import {
|
||||
MessageSquare, FileText, Database,
|
||||
Settings, ChevronLeft, Trash2, CheckCircle2,
|
||||
@@ -49,6 +50,7 @@ const WorkspacePage: React.FC<WorkspacePageProps> = ({ standalone = false }) =>
|
||||
|
||||
const [activeTab, setActiveTab] = useState('chat');
|
||||
const [isPdfOpen, setIsPdfOpen] = useState(false);
|
||||
const [uploadModalVisible, setUploadModalVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (kbId) {
|
||||
@@ -211,7 +213,7 @@ const WorkspacePage: React.FC<WorkspacePageProps> = ({ standalone = false }) =>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => navigate('/knowledge-base/dashboard')}
|
||||
className="flex items-center text-slate-300 hover:text-white hover:bg-slate-700/50 px-3 py-2 rounded-lg transition-all group border border-slate-700 hover:border-slate-600"
|
||||
className="flex items-center text-slate-400 bg-slate-800 hover:bg-slate-700 hover:text-white px-3 py-2 rounded-lg transition-all group"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1.5 group-hover:-translate-x-0.5 transition-transform" />
|
||||
<span className="text-sm font-medium">返回知识库列表</span>
|
||||
@@ -226,16 +228,16 @@ const WorkspacePage: React.FC<WorkspacePageProps> = ({ standalone = false }) =>
|
||||
</div>
|
||||
|
||||
{/* 中间:Tab切换(精致胶囊按钮) */}
|
||||
<div className="flex items-center bg-slate-800/60 rounded-xl p-1 border border-slate-700">
|
||||
<div className="flex items-center bg-slate-800 rounded-xl p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`flex items-center px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === 'chat'
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-700/50'
|
||||
: 'text-slate-400 bg-transparent hover:text-white hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className={`w-4 h-4 mr-2 ${activeTab === 'chat' ? '' : 'opacity-70'}`} />
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
智能问答
|
||||
</button>
|
||||
<button
|
||||
@@ -243,13 +245,13 @@ const WorkspacePage: React.FC<WorkspacePageProps> = ({ standalone = false }) =>
|
||||
className={`flex items-center px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === 'assets'
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-700/50'
|
||||
: 'text-slate-400 bg-transparent hover:text-white hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<FileText className={`w-4 h-4 mr-2 ${activeTab === 'assets' ? '' : 'opacity-70'}`} />
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
知识资产
|
||||
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded ${
|
||||
activeTab === 'assets' ? 'bg-blue-500' : 'bg-slate-700'
|
||||
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full ${
|
||||
activeTab === 'assets' ? 'bg-blue-500 text-white' : 'bg-slate-600 text-slate-300'
|
||||
}`}>
|
||||
{documents.length}
|
||||
</span>
|
||||
@@ -258,13 +260,13 @@ const WorkspacePage: React.FC<WorkspacePageProps> = ({ standalone = false }) =>
|
||||
|
||||
{/* 右侧:标签 + 设置 + 头像 */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-xs text-slate-400 border border-slate-700 px-2.5 py-1 rounded-md bg-slate-800/50">
|
||||
<span className="text-xs text-slate-400 px-2.5 py-1 rounded-md bg-slate-800">
|
||||
{currentKb.fileCount || documents.length} 篇文档
|
||||
</span>
|
||||
<button className="text-slate-400 hover:text-white p-2 rounded-lg hover:bg-slate-700/50 transition-colors">
|
||||
<button className="text-slate-400 bg-slate-800 hover:bg-slate-700 hover:text-white p-2 rounded-lg transition-colors">
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center text-white font-bold text-xs shadow ring-2 ring-slate-700">
|
||||
<div className="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center text-white font-bold text-xs shadow">
|
||||
DL
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,33 +399,50 @@ const WorkspacePage: React.FC<WorkspacePageProps> = ({ standalone = false }) =>
|
||||
<Button icon={<Filter className="w-4 h-4" />} className="shadow-sm">
|
||||
筛选
|
||||
</Button>
|
||||
<Button type="primary" icon={<Plus className="w-4 h-4" />} className="shadow-md font-medium">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
className="shadow-md font-medium"
|
||||
onClick={() => setUploadModalVisible(true)}
|
||||
>
|
||||
上传新文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文档表格 */}
|
||||
{/* 文档表格 - 参考原型图精细化设计 */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm flex-1 overflow-hidden">
|
||||
<Table
|
||||
dataSource={documents}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
scroll={{ y: '100%' }}
|
||||
scroll={{ y: 'calc(100vh - 300px)' }}
|
||||
size="middle"
|
||||
className="pkb-document-table"
|
||||
rowSelection={{
|
||||
type: 'checkbox',
|
||||
columnWidth: 48,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'filename',
|
||||
key: 'filename',
|
||||
width: 320,
|
||||
ellipsis: true,
|
||||
render: (text) => (
|
||||
<div className="flex items-center py-1">
|
||||
<div className="w-9 h-9 bg-red-50 text-red-500 rounded-lg flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<FileText className="w-5 h-5" />
|
||||
<div className="w-8 h-8 bg-red-50 text-red-500 rounded-lg flex items-center justify-center mr-3 flex-shrink-0">
|
||||
<FileText className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="font-bold text-slate-700 text-sm">{text}</span>
|
||||
<span
|
||||
className="font-medium text-blue-600 hover:text-blue-700 cursor-pointer text-sm truncate"
|
||||
title={text}
|
||||
style={{ maxWidth: '240px', display: 'block' }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -431,8 +450,10 @@ const WorkspacePage: React.FC<WorkspacePageProps> = ({ standalone = false }) =>
|
||||
title: '解析状态 (MinerU Pipeline)',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 200,
|
||||
align: 'center',
|
||||
render: (status, record) => (
|
||||
<div className="flex flex-col space-y-1.5 max-w-[180px]">
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
{getStatusBadge(status)}
|
||||
{status !== 'completed' && status !== 'error' && (
|
||||
<Progress
|
||||
@@ -440,6 +461,7 @@ const WorkspacePage: React.FC<WorkspacePageProps> = ({ standalone = false }) =>
|
||||
size="small"
|
||||
strokeColor="#3b82f6"
|
||||
trailColor="#e5e7eb"
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -449,9 +471,11 @@ const WorkspacePage: React.FC<WorkspacePageProps> = ({ standalone = false }) =>
|
||||
title: '文件大小',
|
||||
dataIndex: 'fileSizeBytes',
|
||||
key: 'fileSizeBytes',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (size) => (
|
||||
<span className="text-slate-500 font-mono text-sm">
|
||||
{(size / 1024 / 1024).toFixed(1)} MB
|
||||
<span className="text-slate-600 font-mono text-sm">
|
||||
{size ? `${(size / 1024 / 1024).toFixed(1)} MB` : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -459,8 +483,10 @@ const WorkspacePage: React.FC<WorkspacePageProps> = ({ standalone = false }) =>
|
||||
title: 'Tokens',
|
||||
dataIndex: 'tokensCount',
|
||||
key: 'tokensCount',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (tokens) => (
|
||||
<span className="text-slate-500 font-mono text-sm">
|
||||
<span className="text-slate-600 font-mono text-sm">
|
||||
{tokens ? `${(tokens / 1000).toFixed(0)}k` : '-'}
|
||||
</span>
|
||||
),
|
||||
@@ -469,20 +495,30 @@ const WorkspacePage: React.FC<WorkspacePageProps> = ({ standalone = false }) =>
|
||||
title: '上传时间',
|
||||
dataIndex: 'uploadedAt',
|
||||
key: 'uploadedAt',
|
||||
width: 160,
|
||||
align: 'center',
|
||||
render: (date) => (
|
||||
<span className="text-slate-400 text-sm">
|
||||
{new Date(date).toLocaleString('zh-CN')}
|
||||
<span className="text-slate-500 text-sm">
|
||||
{date ? new Date(date).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}) : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
align: 'right',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<button
|
||||
onClick={() => handleDeleteDocument(record.id)}
|
||||
className="text-slate-400 hover:text-red-500 p-2 rounded-lg hover:bg-red-50 transition-colors"
|
||||
title="删除文档"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -505,6 +541,26 @@ const WorkspacePage: React.FC<WorkspacePageProps> = ({ standalone = false }) =>
|
||||
background-position: 0 0, 10px 10px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 上传文档弹窗 */}
|
||||
<Modal
|
||||
title="上传新文档"
|
||||
open={uploadModalVisible}
|
||||
onCancel={() => setUploadModalVisible(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<DocumentUpload
|
||||
kbId={kbId!}
|
||||
onUploadSuccess={() => {
|
||||
fetchDocuments(kbId!);
|
||||
setUploadModalVisible(false);
|
||||
message.success('文档上传成功!');
|
||||
}}
|
||||
maxDocuments={50}
|
||||
currentDocumentCount={documents.length}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -222,3 +222,4 @@ export const useKnowledgeBaseStore = create<KnowledgeBaseState>((set, get) => ({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,39 +1,154 @@
|
||||
/**
|
||||
* PKB Workspace 样式优化
|
||||
* 根据差距文档进行精细化调整
|
||||
* 根据原型图进行精细化调整
|
||||
*/
|
||||
|
||||
/* 1. 全局字体和排版优化 */
|
||||
/* ========================================
|
||||
1. 文档资产表格 - 参考原型图精细化设计
|
||||
======================================== */
|
||||
|
||||
.pkb-document-table .ant-table {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 表头样式 - 浅灰背景,深灰文字 */
|
||||
.pkb-document-table .ant-table-thead > tr > th {
|
||||
background-color: #f9fafb;
|
||||
color: #6b7280;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background-color: #fafafa !important;
|
||||
color: #374151 !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 13px !important;
|
||||
padding: 16px 20px !important;
|
||||
border-bottom: 1px solid #e5e7eb !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 表头首列(复选框)特殊处理 */
|
||||
.pkb-document-table .ant-table-thead > tr > th.ant-table-selection-column {
|
||||
padding-left: 24px !important;
|
||||
}
|
||||
|
||||
/* 表体单元格样式 */
|
||||
.pkb-document-table .ant-table-tbody > tr > td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
padding: 16px 20px !important;
|
||||
border-bottom: 1px solid #f3f4f6 !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 表体首列(复选框)特殊处理 */
|
||||
.pkb-document-table .ant-table-tbody > tr > td.ant-table-selection-column {
|
||||
padding-left: 24px !important;
|
||||
}
|
||||
|
||||
/* 行悬停效果 */
|
||||
.pkb-document-table .ant-table-tbody > tr:hover > td {
|
||||
background-color: #f9fafb;
|
||||
transition: background-color 0.2s ease;
|
||||
background-color: #f9fafb !important;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* 2. 圆角统一 */
|
||||
/* 选中行背景 */
|
||||
.pkb-document-table .ant-table-tbody > tr.ant-table-row-selected > td {
|
||||
background-color: #eff6ff !important;
|
||||
}
|
||||
|
||||
/* 选中行悬停 */
|
||||
.pkb-document-table .ant-table-tbody > tr.ant-table-row-selected:hover > td {
|
||||
background-color: #dbeafe !important;
|
||||
}
|
||||
|
||||
/* 行过渡动画 */
|
||||
.pkb-document-table .ant-table-tbody > tr {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
/* 最后一行去掉底部边框 */
|
||||
.pkb-document-table .ant-table-tbody > tr:last-child > td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
2. 复选框样式优化
|
||||
======================================== */
|
||||
|
||||
.pkb-document-table .ant-checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pkb-document-table .ant-checkbox-inner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.pkb-document-table .ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
3. 表格容器圆角
|
||||
======================================== */
|
||||
|
||||
.pkb-document-table .ant-table-container {
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 3. 动画优化 */
|
||||
.pkb-document-table .ant-table-content {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
4. 滚动条样式
|
||||
======================================== */
|
||||
|
||||
.pkb-document-table .ant-table-body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.pkb-document-table .ant-table-body::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pkb-document-table .ant-table-body::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pkb-document-table .ant-table-body::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
5. 空数据状态
|
||||
======================================== */
|
||||
|
||||
.pkb-document-table .ant-table-placeholder {
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.pkb-document-table .ant-empty-description {
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
6. 加载状态
|
||||
======================================== */
|
||||
|
||||
.pkb-document-table .ant-spin-container {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
7. 动画效果
|
||||
======================================== */
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -49,7 +164,89 @@
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 4. 表格行悬停优化 */
|
||||
.pkb-document-table .ant-table-tbody > tr {
|
||||
transition: all 0.2s ease;
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
8. 文件名链接样式
|
||||
======================================== */
|
||||
|
||||
.pkb-document-table .filename-link {
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pkb-document-table .filename-link:hover {
|
||||
color: #1d4ed8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
9. 状态徽章优化
|
||||
======================================== */
|
||||
|
||||
.pkb-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pkb-status-badge.completed {
|
||||
background-color: #ecfdf5;
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.pkb-status-badge.processing {
|
||||
background-color: #eff6ff;
|
||||
color: #2563eb;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.pkb-status-badge.error {
|
||||
background-color: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
10. 响应式调整
|
||||
======================================== */
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.pkb-document-table .ant-table-thead > tr > th,
|
||||
.pkb-document-table .ant-table-tbody > tr > td {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.pkb-document-table .ant-table-thead > tr > th,
|
||||
.pkb-document-table .ant-table-tbody > tr > td {
|
||||
padding: 10px 12px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,3 +39,4 @@ export interface BatchTemplate {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user