Files
YG_FT/frontend/src/views/data-process/create/useDataProcessSourceUpload.ts

199 lines
6.4 KiB
TypeScript
Raw Normal View History

import { ref, type Ref } from 'vue'
import {
getDataProcessSourceContent,
uploadDataProcessSourceFiles,
type DataProcessSourceFile,
} from '@/api/modules/dataProcess'
import type { ProcessType, UploadedDataFile } from './types'
const BINARY_FILE_EXTENSIONS = new Set(['xlsx', 'pdf', 'docx', 'pptx'])
const STRUCTURED_FILE_EXTENSIONS = new Set(['json', 'jsonl', 'ndjson', 'csv', 'tsv', 'xlsx'])
const UNSTRUCTURED_FILE_EXTENSIONS = new Set([
'txt', 'md', 'markdown', 'pdf', 'docx', 'pptx', 'json', 'jsonl', 'ndjson',
])
const LEGACY_OFFICE_EXTENSIONS = new Set(['doc', 'xls', 'ppt'])
const MAX_SOURCE_FILE_BYTES = 200 * 1024 * 1024
const MAX_SOURCE_FILE_COUNT = 20
const MAX_SOURCE_BATCH_BYTES = 500 * 1024 * 1024
interface SourceUploadJob {
uid: string
file: File
extension: string
}
interface SourceUploadOptions {
taskId: Ref<string | null>
uploadedFiles: Ref<UploadedDataFile[]>
onUploaded: (file: UploadedDataFile) => void
}
type SourceFileValidation =
| { valid: true; extension: string }
| { valid: false; severity: 'error' | 'warning'; message: string }
export function validateSourceFileSelection(
raw: File,
processType: ProcessType,
selectedFiles: UploadedDataFile[],
): SourceFileValidation {
if (raw.size > MAX_SOURCE_FILE_BYTES) {
return { valid: false, severity: 'warning', message: '单文件不能超过 200MB' }
}
const extension = raw.name.split('.').pop()?.toLowerCase() ?? ''
if (LEGACY_OFFICE_EXTENSIONS.has(extension)) {
return {
valid: false,
severity: 'error',
message: '旧版 DOC、XLS、PPT 文件暂不支持,请分别转换为 DOCX、XLSX、PPTX 后上传',
}
}
const supportedExtensions = processType === 'unstructured'
? UNSTRUCTURED_FILE_EXTENSIONS
: STRUCTURED_FILE_EXTENSIONS
if (!supportedExtensions.has(extension)) {
return {
valid: false,
severity: 'error',
message: processType === 'unstructured'
? '非结构化数据支持 TXT、MD、MARKDOWN、PDF、DOCX、PPTX、JSON、JSONL、NDJSON'
: '结构化数据支持 JSON、JSONL、NDJSON、CSV、TSV、XLSX',
}
}
if (selectedFiles.some((file) => file.name === raw.name && file.size === raw.size)) {
return { valid: false, severity: 'warning', message: '同名且同大小的文件已经选择' }
}
if (selectedFiles.length >= MAX_SOURCE_FILE_COUNT) {
return { valid: false, severity: 'warning', message: `每个任务最多选择 ${MAX_SOURCE_FILE_COUNT} 个文件` }
}
const selectedBytes = selectedFiles.reduce((total, file) => total + file.size, 0)
if (selectedBytes + raw.size > MAX_SOURCE_BATCH_BYTES) {
return { valid: false, severity: 'warning', message: '当前任务选择的文件总大小不能超过 500MB' }
}
return { valid: true, extension }
}
export function mapDataProcessSourceFile(
file: DataProcessSourceFile,
content = '',
): UploadedDataFile {
return {
uid: String(file.id),
sourceFileId: String(file.id),
name: file.name,
size: file.size_bytes,
count: file.record_count,
content,
fileFormat: file.file_format,
checksumSha256: file.checksum_sha256,
status: 'ready',
uploadProgress: 100,
previewStatus: 'waiting',
previewProgress: 0,
}
}
function getUploadErrorMessage(error: unknown) {
const responseData = (error as {
response?: { data?: { detail?: string | { message?: string }; message?: string } }
})?.response?.data
const detail = responseData?.detail
if (typeof detail === 'string') return detail
if (detail?.message) return detail.message
if (responseData?.message) return responseData.message
if (error instanceof Error && error.message) return error.message
return '上传失败,请删除该文件后重新选择'
}
export function useDataProcessSourceUpload(options: SourceUploadOptions) {
const sourceUploading = ref(false)
const queue: SourceUploadJob[] = []
let worker: Promise<void> | null = null
async function uploadOne(job: SourceUploadJob) {
const pending = options.uploadedFiles.value.find((file) => String(file.uid) === job.uid)
if (!pending) return
const currentTaskId = options.taskId.value
if (!currentTaskId) {
pending.status = 'failed'
pending.uploadError = '任务尚未创建,请返回模型选择步骤后重试'
return
}
pending.status = 'uploading'
pending.uploadProgress = 0
pending.uploadError = undefined
try {
let content = ''
if (!BINARY_FILE_EXTENSIONS.has(job.extension)) {
try {
content = new TextDecoder('utf-8', { fatal: true }).decode(await job.file.arrayBuffer())
} catch {
throw new Error('文本文件不是有效的 UTF-8 编码,请转换编码后重试')
}
if (!content.trim()) throw new Error('不能上传空文件')
}
const uploaded = await uploadDataProcessSourceFiles(currentTaskId, [job.file], (progress) => {
pending.uploadProgress = progress
})
const source = uploaded.files[0]
if (!source) throw new Error('后端未返回源文件记录')
// 先登记后端 ID确保正文读取失败时仍可正确删除已落库的文件。
Object.assign(pending, mapDataProcessSourceFile(source), {
rawFile: job.file,
status: 'uploading',
uploadProgress: 99,
})
if (BINARY_FILE_EXTENSIONS.has(job.extension)) {
try {
const parsed = await getDataProcessSourceContent(currentTaskId, source.id, {
start_line: 1,
line_count: 10_000,
})
pending.content = parsed.content
} catch {
// 原文件已经成功落库,正文稍后仍可由预览构建接口读取,不重复上传。
}
} else {
pending.content = content
}
pending.status = 'ready'
pending.uploadProgress = 100
options.onUploaded(pending)
} catch (error) {
pending.status = 'failed'
pending.uploadError = getUploadErrorMessage(error)
}
}
async function drainQueue() {
sourceUploading.value = true
try {
while (queue.length) {
const job = queue.shift()
if (job) await uploadOne(job)
}
} finally {
sourceUploading.value = false
}
}
function enqueueSourceUpload(job: SourceUploadJob) {
queue.push(job)
if (worker) return
worker = drainQueue().finally(() => {
worker = null
})
}
return {
enqueueSourceUpload,
sourceUploading,
}
}