447 lines
21 KiB
TypeScript
447 lines
21 KiB
TypeScript
import {
|
||
ArrowLeftOutlined,
|
||
CheckCircleOutlined,
|
||
DeleteOutlined,
|
||
ExclamationCircleOutlined,
|
||
FileTextOutlined,
|
||
InboxOutlined,
|
||
LoadingOutlined,
|
||
QuestionCircleOutlined,
|
||
} from '@ant-design/icons';
|
||
import type { UploadFile } from 'antd';
|
||
import {
|
||
Button,
|
||
Card,
|
||
Checkbox,
|
||
Divider,
|
||
Empty,
|
||
Input,
|
||
InputNumber,
|
||
Progress,
|
||
Select,
|
||
Spin,
|
||
Tooltip,
|
||
Upload,
|
||
} from 'antd';
|
||
import { useEffect, useState } from 'react';
|
||
import type { Segment } from '~/api/dify-dataset/type';
|
||
import { useDocumentUpload } from '~/hooks/dify-dataset-manager/document-upload';
|
||
import type { DocumentUploadProps, UploadedDocument } from '~/types/dify-dataset-manager/document-upload';
|
||
import { SUPPORTED_FORMATS } from '~/types/dify-dataset-manager/document-upload';
|
||
|
||
const { Dragger } = Upload;
|
||
|
||
/**
|
||
* 文档上传组件
|
||
* 支持多文件上传,两步流程:选择文件 → 上传并配置分段
|
||
*/
|
||
export default function DocumentUpload({
|
||
datasetId,
|
||
onClose,
|
||
onSuccess,
|
||
}: DocumentUploadProps) {
|
||
const {
|
||
// 状态
|
||
step,
|
||
fileList,
|
||
uploadedDocuments,
|
||
currentSettings,
|
||
previewLoading,
|
||
|
||
// 方法
|
||
handleFileChange,
|
||
handleRemoveFile,
|
||
handleNextStep,
|
||
handleDocumentChange,
|
||
handleReprocess,
|
||
handlePrevStep,
|
||
handleGoToDocuments,
|
||
updateCurrentSettings,
|
||
|
||
// 计算属性方法
|
||
getCurrentDocument,
|
||
getCurrentProgress,
|
||
getStatusText,
|
||
isCurrentDocProcessing,
|
||
getCompletionStats,
|
||
} = useDocumentUpload(datasetId, onClose, onSuccess);
|
||
|
||
const selectedFiles = fileList.filter((f: UploadFile) => f.originFileObj).map((f: UploadFile) => f.originFileObj as File);
|
||
|
||
// 平滑进度条逻辑
|
||
const [displayPercent, setDisplayPercent] = useState(0);
|
||
const targetPercent = getCurrentProgress();
|
||
|
||
useEffect(() => {
|
||
if (targetPercent > displayPercent) {
|
||
// 如果目标进度大于当前显示进度,启动动画
|
||
const diff = targetPercent - displayPercent;
|
||
// 动态步长:差距越大跑得越快,但最小步长为1
|
||
const step = Math.max(1, Math.ceil(diff / 10));
|
||
|
||
const timer = requestAnimationFrame(() => {
|
||
setDisplayPercent(prev => Math.min(targetPercent, prev + step));
|
||
});
|
||
|
||
return () => cancelAnimationFrame(timer);
|
||
} else if (targetPercent < displayPercent && targetPercent === 0) {
|
||
// 如果目标重置为0(例如重新开始),立即重置
|
||
setDisplayPercent(0);
|
||
}
|
||
}, [targetPercent, displayPercent]);
|
||
|
||
/**
|
||
* 渲染步骤指示器(两步流程)
|
||
*/
|
||
const renderSteps = () => (
|
||
<div className="upload-steps">
|
||
<div className={`step-item ${step === 1 ? 'active' : ''} ${step > 1 ? 'completed' : ''}`}>
|
||
<span className="step-number">1</span>
|
||
<span className="step-title">选择数据源</span>
|
||
</div>
|
||
<div className={`step-divider ${step > 1 ? 'completed' : ''}`}></div>
|
||
<div className={`step-item ${step === 2 ? 'active' : ''}`}>
|
||
<span className="step-number">2</span>
|
||
<span className="step-title">文本分段与清洗</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
/**
|
||
* 渲染第一步:选择文件(支持多文件)
|
||
*/
|
||
const renderStep1 = () => (
|
||
<div className="upload-step-content step1">
|
||
<h2 className="step-heading">上传文本文件</h2>
|
||
<p className="step-description">
|
||
文档需上传至知识智能理解法治知识库,广东烟草智能理解将按照于知识库,你可以在聊后指数文档所据案中检索它
|
||
</p>
|
||
|
||
<div className="file-drop-zone">
|
||
<Dragger
|
||
fileList={fileList}
|
||
onChange={handleFileChange}
|
||
beforeUpload={() => false}
|
||
multiple={true}
|
||
accept=".txt,.md,.mdx,.pdf,.html,.htm,.xlsx,.xls,.docx,.csv,.vtt,.properties"
|
||
showUploadList={false}
|
||
>
|
||
<p className="ant-upload-drag-icon">
|
||
<InboxOutlined />
|
||
</p>
|
||
<p className="ant-upload-text">拖拽文件或至此,或者 <span className="upload-link">选择文件</span></p>
|
||
<p className="ant-upload-hint">
|
||
已支持 {SUPPORTED_FORMATS},每个文件不超过 15MB。支持批量上传多个文件。
|
||
</p>
|
||
</Dragger>
|
||
</div>
|
||
|
||
{/* 已选文件列表 */}
|
||
{selectedFiles.length > 0 && (
|
||
<div className="selected-files-section">
|
||
<h3 className="section-subtitle">嵌入已就绪 ({selectedFiles.length} 个文件)</h3>
|
||
<div className="selected-files-list">
|
||
{fileList.map((file: UploadFile) => (
|
||
<div key={file.uid} className="selected-file-item">
|
||
<FileTextOutlined className="file-icon" />
|
||
<div className="file-info">
|
||
<span className="file-name">{file.name}</span>
|
||
<span className="file-size">
|
||
{file.originFileObj
|
||
? `${file.originFileObj.type?.split('/')[1]?.toUpperCase() || 'FILE'},${(file.originFileObj.size / 1024 / 1024).toFixed(2)}MB`
|
||
: ''}
|
||
</span>
|
||
</div>
|
||
<Button
|
||
type="text"
|
||
icon={<DeleteOutlined />}
|
||
onClick={() => handleRemoveFile(file)}
|
||
className="remove-file-btn"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="step-actions">
|
||
<Button
|
||
type="primary"
|
||
onClick={handleNextStep}
|
||
disabled={selectedFiles.length === 0}
|
||
className="next-btn"
|
||
>
|
||
下一步 →
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
/**
|
||
* 渲染第二步:分段配置与预览
|
||
* 左侧始终显示配置面板,右侧预览框内显示进度或分段内容
|
||
*/
|
||
const renderStep2 = () => {
|
||
const currentDoc = getCurrentDocument();
|
||
const isProcessing = isCurrentDocProcessing();
|
||
const stats = getCompletionStats();
|
||
|
||
return (
|
||
<div className="upload-step-content step2">
|
||
{/* 分段配置与预览 */}
|
||
<div className="document-detail-content">
|
||
{/* 左侧设置区域 */}
|
||
<div className="settings-panel">
|
||
<div className="settings-section">
|
||
<h3 className="section-title">分段设置</h3>
|
||
|
||
{/* 分段标识符 */}
|
||
<div className="setting-item">
|
||
<label className="setting-label">
|
||
分段标识符
|
||
<Tooltip title="系统会在遇到指定分隔符时自动分段,默认值为 \n\n(按段落分段)">
|
||
<QuestionCircleOutlined className="help-icon" />
|
||
</Tooltip>
|
||
</label>
|
||
<Input
|
||
value={currentSettings.separator}
|
||
onChange={(e) => updateCurrentSettings('separator', e.target.value)}
|
||
placeholder="\n\n"
|
||
className="setting-input"
|
||
disabled={isProcessing}
|
||
/>
|
||
</div>
|
||
|
||
{/* 分段最大长度 */}
|
||
<div className="setting-item">
|
||
<label className="setting-label">
|
||
分段最大长度
|
||
<Tooltip title="指定每个分段允许的最大字符数(100-4000),超过此限制系统会强制分段">
|
||
<QuestionCircleOutlined className="help-icon" />
|
||
</Tooltip>
|
||
</label>
|
||
<div className="setting-input-with-suffix">
|
||
<InputNumber
|
||
value={currentSettings.maxTokens}
|
||
onChange={(value) => updateCurrentSettings('maxTokens', value || 1024)}
|
||
min={100}
|
||
max={4000}
|
||
className="setting-input-number"
|
||
disabled={isProcessing}
|
||
/>
|
||
<span className="input-suffix">characters</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 分段重叠长度 */}
|
||
<div className="setting-item">
|
||
<label className="setting-label">
|
||
分段重叠长度
|
||
<Tooltip title="相邻分段之间重叠的字符数,有助于保持上下文连贯性">
|
||
<QuestionCircleOutlined className="help-icon" />
|
||
</Tooltip>
|
||
</label>
|
||
<div className="setting-input-with-suffix">
|
||
<InputNumber
|
||
value={currentSettings.chunkOverlap}
|
||
onChange={(value) => updateCurrentSettings('chunkOverlap', value || 50)}
|
||
min={0}
|
||
max={500}
|
||
className="setting-input-number"
|
||
disabled={isProcessing}
|
||
/>
|
||
<span className="input-suffix">characters</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Divider />
|
||
|
||
{/* 文本预处理规则 */}
|
||
<div className="settings-section">
|
||
<h3 className="section-title">文本预处理规则</h3>
|
||
<div className="checkbox-group">
|
||
<Checkbox
|
||
checked={currentSettings.removeExtraSpaces}
|
||
onChange={(e) => updateCurrentSettings('removeExtraSpaces', e.target.checked)}
|
||
disabled={isProcessing}
|
||
>
|
||
替换掉连续的空格、换行符和制表符
|
||
</Checkbox>
|
||
<Checkbox
|
||
checked={currentSettings.removeUrlsEmails}
|
||
onChange={(e) => updateCurrentSettings('removeUrlsEmails', e.target.checked)}
|
||
disabled={isProcessing}
|
||
>
|
||
删除所有 URL 和电子邮件地址
|
||
</Checkbox>
|
||
</div>
|
||
</div>
|
||
|
||
<Divider />
|
||
|
||
{/* 索引方式 */}
|
||
<div className="settings-section">
|
||
<h3 className="section-title">索引方式</h3>
|
||
<div className="index-options">
|
||
<div
|
||
className={`index-option ${currentSettings.indexingTechnique === 'high_quality' ? 'active' : ''} ${isProcessing ? 'disabled' : ''}`}
|
||
onClick={() => !isProcessing && updateCurrentSettings('indexingTechnique', 'high_quality')}
|
||
>
|
||
<span className="option-radio"></span>
|
||
<span className="option-label">高质量</span>
|
||
<span className="option-badge recommended">推荐</span>
|
||
</div>
|
||
<div
|
||
className={`index-option ${currentSettings.indexingTechnique === 'economy' ? 'active' : ''} ${isProcessing ? 'disabled' : ''}`}
|
||
onClick={() => !isProcessing && updateCurrentSettings('indexingTechnique', 'economy')}
|
||
>
|
||
<span className="option-radio"></span>
|
||
<span className="option-label">经济</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
<div className="settings-actions">
|
||
<Button onClick={handlePrevStep} disabled={isProcessing}>
|
||
<ArrowLeftOutlined /> 上一步
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleReprocess}
|
||
loading={isProcessing}
|
||
disabled={isProcessing || !currentDoc?.documentId}
|
||
>
|
||
更新嵌入配置
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧预览区域 */}
|
||
<div className="preview-panel">
|
||
<Card
|
||
title={
|
||
<div className="preview-header">
|
||
<span>预览</span>
|
||
{uploadedDocuments.length > 0 && (
|
||
<>
|
||
<Select
|
||
value={currentDoc?.documentId || currentDoc?.file.name}
|
||
style={{ width: 500 }}
|
||
onChange={handleDocumentChange}
|
||
options={uploadedDocuments.map((doc: UploadedDocument) => ({
|
||
value: doc.documentId || doc.file.name,
|
||
label: (
|
||
<span className="file-select-option">
|
||
{doc.stage === 'completed' && <CheckCircleOutlined style={{ color: '#52c41a', marginRight: 4 }} />}
|
||
{(doc.stage === 'uploading' || doc.stage === 'indexing') && <LoadingOutlined style={{ color: '#00684a', marginRight: 4 }} />}
|
||
{doc.stage === 'error' && <ExclamationCircleOutlined style={{ color: '#ff4d4f', marginRight: 4 }} />}
|
||
{doc.file.name}
|
||
</span>
|
||
),
|
||
}))}
|
||
/>
|
||
{!isProcessing && currentDoc?.segments && (
|
||
<span className="segment-count">
|
||
{currentDoc.segments.length} 段块
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
}
|
||
className="preview-card"
|
||
>
|
||
{/* 处理进度(在预览框内显示) */}
|
||
{isProcessing ? (
|
||
<div className="preview-processing">
|
||
<div className="processing-file">
|
||
<FileTextOutlined className="file-icon" />
|
||
<span className="file-name">{currentDoc?.file.name}</span>
|
||
<LoadingOutlined className="status-icon loading" />
|
||
</div>
|
||
<Progress
|
||
percent={displayPercent}
|
||
status="active"
|
||
strokeColor={{
|
||
'0%': '#00684a',
|
||
'100%': '#52c41a',
|
||
}}
|
||
/>
|
||
<div className="status-text">{getStatusText()}</div>
|
||
</div>
|
||
) : currentDoc?.stage === 'error' ? (
|
||
<div className="preview-error">
|
||
<ExclamationCircleOutlined className="error-icon" />
|
||
<div className="error-text">{currentDoc.error || '处理失败'}</div>
|
||
</div>
|
||
) : previewLoading ? (
|
||
<div className="preview-loading">
|
||
<Spin size="large" />
|
||
<div className="loading-text">加载中...</div>
|
||
</div>
|
||
) : (currentDoc?.segments?.length ?? 0) === 0 ? (
|
||
<div className="preview-empty">
|
||
<Empty description="等待处理完成后显示分段预览" />
|
||
</div>
|
||
) : (
|
||
<div className="preview-segments">
|
||
{currentDoc?.segments.map((segment: Segment, index: number) => (
|
||
<div key={segment.id} className="segment-item">
|
||
<div className="segment-header">
|
||
<span className="segment-index">#{index + 1}</span>
|
||
<span className="segment-chars">
|
||
{segment.word_count} 字符
|
||
</span>
|
||
</div>
|
||
<div className="segment-content">
|
||
{segment.content}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 完成状态底部操作 */}
|
||
{stats.completed > 0 && (
|
||
<div className="completion-actions">
|
||
<span className="completion-stats">
|
||
{stats.completed}/{stats.total} 个文档处理完成
|
||
</span>
|
||
<Button type="primary" onClick={handleGoToDocuments}>
|
||
前往知识库
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="document-upload-page">
|
||
{/* 页面头部 */}
|
||
<div className="upload-header">
|
||
<Button
|
||
type="text"
|
||
icon={<ArrowLeftOutlined />}
|
||
onClick={onClose}
|
||
className="back-btn"
|
||
>
|
||
知识库
|
||
</Button>
|
||
{renderSteps()}
|
||
</div>
|
||
|
||
{/* 内容区域 */}
|
||
<div className="upload-content">
|
||
{step === 1 && renderStep1()}
|
||
{step === 2 && renderStep2()}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|