Files
YG_FT/frontend/src/views/data-process/create/SourceUploadStep.vue

731 lines
21 KiB
Vue
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.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { UploadFile } from 'element-plus'
import type { ExternalDataSource, ProcessType, UploadedDataFile } from './types'
const props = defineProps<{
processType: ProcessType
uploadedFiles: UploadedDataFile[]
externalSource: ExternalDataSource
externalPulling: boolean
externalConnected: boolean
previewBuilding: boolean
sourceUploading: boolean
}>()
const emit = defineEmits<{
'update:externalSource': [value: ExternalDataSource]
'file-change': [file: UploadFile]
'remove-file': [uid: string | number]
'use-sample': []
'test-connection': []
'pull-data': []
}>()
const DATA_SOURCE_TYPES = [
{ value: 'postgresql', label: 'PostgreSQL' },
]
const AUTH_MODES = [
{ value: 'none', label: '免鉴权' },
{ value: 'basic', label: '账号密码' },
]
const FILE_PAGE_SIZE = 10
const currentFilePage = ref(1)
type FileStage = 'queued' | 'uploading' | 'waiting' | 'processing' | 'success' | 'upload-failed' | 'preview-failed'
const FILE_STAGE_META: Record<FileStage, { label: string; icon: string }> = {
queued: { label: '等待上传', icon: 'fa-clock-o' },
uploading: { label: '正在上传', icon: 'fa-cloud-upload' },
waiting: { label: '等待切分', icon: 'fa-clock-o' },
processing: { label: '正在切分', icon: 'fa-spinner fa-spin' },
success: { label: '切分完成', icon: 'fa-check-circle' },
'upload-failed': { label: '上传失败', icon: 'fa-exclamation-circle' },
'preview-failed': { label: '切分失败', icon: 'fa-exclamation-circle' },
}
const isExternal = computed(() => props.processType === 'external')
const uploadAccept = computed(() => props.processType === 'unstructured'
? '.txt,.md,.markdown,.pdf,.docx,.pptx,.json,.jsonl,.ndjson'
: '.json,.jsonl,.ndjson,.csv,.tsv,.xlsx')
const pagedUploadedFiles = computed(() => {
const start = (currentFilePage.value - 1) * FILE_PAGE_SIZE
return props.uploadedFiles.slice(start, start + FILE_PAGE_SIZE)
})
watch(() => props.uploadedFiles.length, (newLength, oldLength) => {
const totalPages = Math.max(1, Math.ceil(newLength / FILE_PAGE_SIZE))
if (newLength > oldLength) {
currentFilePage.value = totalPages
return
}
currentFilePage.value = Math.min(currentFilePage.value, totalPages)
})
watch(() => props.processType, () => {
currentFilePage.value = 1
})
function updateExternalField<K extends keyof ExternalDataSource>(field: K, value: ExternalDataSource[K]) {
emit('update:externalSource', { ...props.externalSource, [field]: value })
}
function formatSize(size: number) {
if (!size) return '0 KB'
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`
return `${(size / 1024).toFixed(1)} KB`
}
function getFileStage(file: UploadedDataFile): FileStage {
if (file.status === 'queued' || file.status === 'uploading') return file.status
if (file.status === 'failed') return 'upload-failed'
if (file.previewStatus === 'processing' || file.previewStatus === 'success') return file.previewStatus
if (file.previewStatus === 'failed') return 'preview-failed'
return 'waiting'
}
function getFileProgress(file: UploadedDataFile) {
const stage = getFileStage(file)
const progress = stage === 'queued' || stage === 'uploading' || stage === 'upload-failed'
? file.uploadProgress
: stage === 'waiting' ? 100 : file.previewProgress ?? 0
return Math.min(100, Math.max(0, progress))
}
function isFileProcessing(file: UploadedDataFile) {
return getFileStage(file) === 'processing'
}
function getFileBarPercentage(file: UploadedDataFile) {
// Element Plus 的不定进度动画需要非零宽度;这里不作为完成百分比展示。
return isFileProcessing(file) ? 100 : getFileProgress(file)
}
function getFileProgressStatus(file: UploadedDataFile): 'success' | 'exception' | undefined {
const stage = getFileStage(file)
if (stage === 'waiting' || stage === 'success') return 'success'
if (stage === 'upload-failed' || stage === 'preview-failed') return 'exception'
return undefined
}
function getFileProgressText(file: UploadedDataFile) {
if (isFileProcessing(file)) return '处理中'
if (getFileStage(file) === 'waiting') return '已上传'
return `${getFileProgress(file)}%`
}
function getFileError(file: UploadedDataFile) {
return file.uploadError || file.previewError
}
</script>
<template>
<section class="source-upload-step" aria-labelledby="source-upload-title">
<div v-if="isExternal" class="form-section external-section">
<div class="section-title-row">
<div>
<h3 id="source-upload-title">数据源配置</h3>
<p>配置并验证外部数据源拉取成功后可在下一步预览数据内容</p>
</div>
</div>
<div class="external-form">
<el-form label-position="top" class="external-grid">
<el-form-item label="数据源类型">
<el-select
:model-value="externalSource.type"
placeholder="请选择数据源类型"
aria-label="数据源类型"
@update:model-value="updateExternalField('type', $event)"
>
<el-option
v-for="item in DATA_SOURCE_TYPES"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="地址 / URL">
<el-input
:model-value="externalSource.url"
placeholder="例如postgresql://db.example.com:5432/my_database"
aria-label="数据源地址或 URL"
@update:model-value="updateExternalField('url', $event)"
/>
</el-form-item>
<el-form-item label="鉴权方式">
<el-select
:model-value="externalSource.authMode"
aria-label="鉴权方式"
@update:model-value="updateExternalField('authMode', $event)"
>
<el-option
v-for="item in AUTH_MODES"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="externalSource.authMode === 'basic'" label="账号">
<el-input
:model-value="externalSource.username"
autocomplete="username"
placeholder="请输入账号"
aria-label="数据源账号"
@update:model-value="updateExternalField('username', $event)"
/>
</el-form-item>
<el-form-item v-if="externalSource.authMode === 'basic'" label="密码">
<el-input
:model-value="externalSource.password"
type="password"
show-password
autocomplete="current-password"
placeholder="请输入密码"
aria-label="数据源密码"
@update:model-value="updateExternalField('password', $event)"
/>
</el-form-item>
<el-form-item label="拉取条数">
<el-input-number
:model-value="externalSource.limit"
:min="1"
:max="100000"
:step="100"
controls-position="right"
aria-label="数据拉取条数"
@update:model-value="updateExternalField('limit', Number($event) || 0)"
/>
</el-form-item>
<el-form-item label="只读查询语句" class="external-query-field">
<el-input
:model-value="externalSource.query"
type="textarea"
:rows="4"
maxlength="20000"
show-word-limit
placeholder="例如SELECT question, answer FROM qa_data ORDER BY id"
aria-label="外部数据源只读查询语句"
@update:model-value="updateExternalField('query', $event)"
/>
<small>只允许单条 SELECT WITH 查询后端会拒绝写入DDL 和多语句</small>
</el-form-item>
</el-form>
<div class="external-actions">
<el-button
:loading="externalPulling && !externalConnected"
:disabled="externalPulling || previewBuilding"
plain
@click="emit('test-connection')"
>
测试连接
</el-button>
<el-button
type="primary"
:loading="externalPulling"
:disabled="externalPulling || previewBuilding"
@click="emit('pull-data')"
>
拉取数据
</el-button>
<span v-if="externalConnected" class="external-status is-connected" role="status">
<i class="fa fa-check-circle" aria-hidden="true" /> 连接正常
</span>
</div>
<section v-if="uploadedFiles.length" class="uploaded-file-list" aria-label="已拉取数据列表">
<div class="uploaded-file-list-header">
<span>已拉取 {{ uploadedFiles.length }} 个数据集</span>
</div>
<div class="uploaded-file-items">
<div v-for="file in pagedUploadedFiles" :key="file.uid" class="uploaded-file">
<span class="file-icon"><i class="fa fa-cloud-download" aria-hidden="true" /></span>
<div class="file-main">
<strong :title="file.name">{{ file.name }}</strong>
<span>
{{ formatSize(file.size) }}
<template v-if="file.count"> · {{ file.count.toLocaleString() }} </template>
</span>
</div>
<div
class="file-preview-progress"
:class="`is-${getFileStage(file)}`"
:title="getFileError(file)"
>
<div class="file-status" role="status" aria-live="polite">
<span>
<i class="fa" :class="FILE_STAGE_META[getFileStage(file)].icon" aria-hidden="true" />
{{ FILE_STAGE_META[getFileStage(file)].label }}
</span>
<span class="file-progress-value">{{ getFileProgressText(file) }}</span>
</div>
<el-progress
:percentage="getFileBarPercentage(file)"
:indeterminate="isFileProcessing(file)"
:duration="1.5"
:stroke-width="5"
:show-text="false"
:status="getFileProgressStatus(file)"
:aria-valuenow="isFileProcessing(file) ? undefined : getFileProgress(file)"
:aria-valuetext="isFileProcessing(file) ? '正在切分进度未知' : getFileProgressText(file)"
/>
<small v-if="getFileError(file)" class="file-preview-error">{{ getFileError(file) }}</small>
</div>
<el-button
link
type="danger"
:disabled="previewBuilding || file.status === 'uploading'"
:aria-label="`删除数据集 ${file.name}`"
@click="emit('remove-file', file.uid)"
>
删除
</el-button>
</div>
</div>
<el-pagination
v-if="uploadedFiles.length > FILE_PAGE_SIZE"
v-model:current-page="currentFilePage"
:page-size="FILE_PAGE_SIZE"
:total="uploadedFiles.length"
:pager-count="5"
small
background
layout="prev, pager, next"
class="uploaded-file-pagination"
aria-label="已拉取数据分页"
/>
</section>
</div>
</div>
<div v-else class="form-section upload-section">
<div class="section-title-row">
<div>
<h3 id="source-upload-title">源数据上传</h3>
<p>上传后可在下一步检查内容和切分效果支持同时添加多个文件</p>
</div>
<el-button
v-if="uploadedFiles.length === 0"
link
type="primary"
:disabled="previewBuilding"
@click="emit('use-sample')"
>
使用示例数据
</el-button>
</div>
<el-upload
v-if="uploadedFiles.length === 0"
drag
multiple
:accept="uploadAccept"
:disabled="previewBuilding"
:auto-upload="false"
:show-file-list="false"
:on-change="(file: UploadFile) => emit('file-change', file)"
aria-label="选择或拖拽源数据文件"
>
<i class="fa fa-cloud-upload upload-icon" aria-hidden="true" />
<div class="el-upload__text">拖拽文件到此处,或<em>点击选择文件</em></div>
<template #tip>
<div class="el-upload__tip">
{{ processType === 'unstructured'
? '支持 TXT、MD、MARKDOWN、PDF、DOCX、PPTX、JSON、JSONL、NDJSON旧版 DOC/PPT 请先转换,单文件不超过 200MB'
: '支持 JSON、JSONL、NDJSON、CSV、TSV、XLSX旧版 XLS 请先转换,单文件不超过 200MB' }}
</div>
</template>
</el-upload>
<section v-else class="uploaded-file-list" aria-label="已上传文件列表">
<div class="uploaded-file-list-header">
<span>
已选择 {{ uploadedFiles.length }} 个文件
<small v-if="sourceUploading" class="upload-queue-status"> · 正在逐个上传</small>
</span>
<div class="continue-upload">
<el-upload
multiple
:accept="uploadAccept"
:disabled="previewBuilding"
:auto-upload="false"
:show-file-list="false"
:on-change="(file: UploadFile) => emit('file-change', file)"
aria-label="继续添加源数据文件"
>
<el-button size="small" type="primary" :disabled="previewBuilding">继续上传</el-button>
</el-upload>
</div>
</div>
<div class="uploaded-file-items">
<div v-for="file in pagedUploadedFiles" :key="file.uid" class="uploaded-file">
<span class="file-icon"><i class="fa fa-file-text-o" aria-hidden="true" /></span>
<div class="file-main">
<strong :title="file.name">{{ file.name }}</strong>
<span>
{{ formatSize(file.size) }}
<template v-if="file.count"> · {{ file.count.toLocaleString() }} 条</template>
</span>
</div>
<div
class="file-preview-progress"
:class="`is-${getFileStage(file)}`"
:title="getFileError(file)"
>
<div class="file-status" role="status" aria-live="polite">
<span>
<i class="fa" :class="FILE_STAGE_META[getFileStage(file)].icon" aria-hidden="true" />
{{ FILE_STAGE_META[getFileStage(file)].label }}
</span>
<span class="file-progress-value">{{ getFileProgressText(file) }}</span>
</div>
<el-progress
:percentage="getFileBarPercentage(file)"
:indeterminate="isFileProcessing(file)"
:duration="1.5"
:stroke-width="5"
:show-text="false"
:status="getFileProgressStatus(file)"
:aria-valuenow="isFileProcessing(file) ? undefined : getFileProgress(file)"
:aria-valuetext="isFileProcessing(file) ? '正在切分,进度未知' : getFileProgressText(file)"
/>
<small v-if="getFileError(file)" class="file-preview-error">{{ getFileError(file) }}</small>
</div>
<el-button
link
type="danger"
:disabled="previewBuilding || file.status === 'uploading'"
:aria-label="`删除文件 ${file.name}`"
@click="emit('remove-file', file.uid)"
>
删除
</el-button>
</div>
</div>
<el-pagination
v-if="uploadedFiles.length > FILE_PAGE_SIZE"
v-model:current-page="currentFilePage"
:page-size="FILE_PAGE_SIZE"
:total="uploadedFiles.length"
:pager-count="5"
small
background
layout="prev, pager, next"
class="uploaded-file-pagination"
aria-label="已上传文件分页"
/>
</section>
</div>
</section>
</template>
<style scoped lang="scss">
.source-upload-step {
width: 100%;
}
.form-section {
padding: 0;
h3 {
margin: 0 0 5px;
color: #2f3747;
font-size: 15px;
font-weight: 650;
}
}
.section-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
p {
margin: 0;
color: #8a93a3;
font-size: 12px;
line-height: 1.6;
}
}
.external-section {
.external-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 20px;
margin-top: 16px;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
:deep(.el-select),
:deep(.el-input),
:deep(.el-input-number) {
width: 100%;
}
}
.external-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 22px;
}
.external-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
&.is-connected {
color: #2ca66a;
}
}
.upload-section :deep(.el-upload) {
width: 100%;
margin-top: 16px;
}
.upload-section :deep(.el-upload-dragger) {
width: 100%;
min-height: 154px;
padding: 32px 20px;
background: #fff;
border-color: #dfe3ea;
transition: border-color 0.18s ease, background-color 0.18s ease;
&:hover,
&:focus-visible {
background: #fafaff;
border-color: #8b82f4;
}
&:focus-visible {
outline: 2px solid #5b50f2;
outline-offset: 2px;
}
}
.upload-icon {
margin-bottom: 12px;
color: #5b50f2;
font-size: 30px;
}
.uploaded-file-list {
margin-top: 20px;
overflow: hidden;
background: #fff;
border: 1px solid #dfe3ea;
border-radius: 8px;
}
.uploaded-file-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
color: #5f6878;
font-size: 12px;
background: #fff;
border-bottom: 1px solid #edf0f5;
}
.continue-upload {
flex: 0 0 auto;
}
.continue-upload :deep(.el-upload) {
width: auto;
margin-top: 0;
}
.upload-queue-status {
color: #5b50f2;
font-size: inherit;
}
.uploaded-file-pagination {
display: flex;
justify-content: flex-end;
padding: 10px 14px;
border-top: 1px solid #edf0f5;
}
.uploaded-file {
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 8px 14px;
border-bottom: 1px solid #edf0f5;
&:last-child {
border-bottom: 0;
}
}
.file-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 28px;
height: 28px;
color: #5b50f2;
font-size: 14px;
background: #f0efff;
border-radius: 7px;
}
.file-main {
display: flex;
flex: 1;
flex-direction: column;
gap: 5px;
min-width: 0;
strong {
overflow: hidden;
color: #273142;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
color: #8a93a3;
font-size: 12px;
}
}
.file-preview-progress {
display: flex;
flex: 0 1 220px;
flex-direction: column;
gap: 5px;
min-width: 150px;
&.is-queued .file-status,
&.is-waiting .file-status {
color: #8a93a3;
}
&.is-uploading .file-status,
&.is-processing .file-status {
color: #5b50f2;
}
&.is-success .file-status {
color: #2ca66a;
}
&.is-upload-failed .file-status,
&.is-preview-failed .file-status,
.file-preview-error {
color: #d94b4b;
}
}
.file-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
> span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.file-progress-value {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.file-preview-error {
overflow: hidden;
font-size: 11px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.uploaded-file :deep(.el-button) {
flex: 0 0 auto;
}
@media (max-width: 900px) {
.external-section .external-grid {
grid-template-columns: minmax(0, 1fr);
}
.uploaded-file {
gap: 8px;
padding: 8px 10px;
}
.file-preview-progress {
flex: 0 1 auto;
min-width: 130px;
}
.file-status {
line-height: 1.4;
white-space: normal;
}
.uploaded-file-pagination {
justify-content: center;
}
}
@media (max-width: 560px) {
.section-title-row {
align-items: flex-start;
flex-direction: column;
}
.uploaded-file-list-header {
align-items: flex-start;
flex-direction: column;
}
.uploaded-file {
align-items: flex-start;
flex-wrap: wrap;
}
.file-main {
min-width: calc(100% - 40px);
}
.file-preview-progress {
flex: 1 0 calc(100% - 38px);
margin-left: 38px;
}
}
@media (prefers-reduced-motion: reduce) {
.upload-section :deep(.el-upload-dragger) {
transition: none;
}
}
</style>