Files
leaudit-platform-frontend/app/components/dify-dataset-manager/document-upload.tsx
T

447 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}