Files
YG-Datasets/frontend/src/views/project/TextSplit.vue
Developer 3e2d07a502 refactor(frontend): 更新项目视图和文本分割页面
- App.vue: 更新样式和路由配置
- ProjectView.vue: 布局调整
- TextSplit.vue: 分割功能完善

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:08:16 +08:00

985 lines
22 KiB
Vue

<template>
<div class="text-split">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<h2 class="page-title">分割生成</h2>
<p class="page-subtitle">选择文件进行智能分割</p>
</div>
<div class="header-actions">
<el-button @click="refreshFiles" class="refresh-btn">
<el-icon><Refresh /></el-icon>
<span>刷新</span>
</el-button>
<el-button
type="primary"
@click="handleBatchGenerateQA"
:disabled="!hasProcessedFiles"
class="generate-btn"
>
<el-icon><ChatDotSquare /></el-icon>
<span>批量生成问答对</span>
</el-button>
</div>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon total">
<el-icon><Document /></el-icon>
</div>
<div class="stat-content">
<span class="stat-value">{{ files.length }}</span>
<span class="stat-label">总文件</span>
</div>
</div>
<div class="stat-card success">
<div class="stat-icon">
<el-icon><CircleCheckFilled /></el-icon>
</div>
<div class="stat-content">
<span class="stat-value">{{ completedFiles }}</span>
<span class="stat-label">已分割</span>
</div>
</div>
<div class="stat-card processing">
<div class="stat-icon">
<el-icon><Loading /></el-icon>
</div>
<div class="stat-content">
<span class="stat-value">{{ processingCount }}</span>
<span class="stat-label">分割中</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon chunks">
<el-icon><List /></el-icon>
</div>
<div class="stat-content">
<span class="stat-value">{{ totalChunks }}</span>
<span class="stat-label">总文本块</span>
</div>
</div>
</div>
<!-- File List / Split View -->
<div class="content-area">
<!-- Empty State -->
<div v-if="!loading && files.length === 0" class="empty-state">
<div class="empty-icon">
<el-icon size="56"><FolderOpened /></el-icon>
</div>
<h3 class="empty-title">暂无可分割文件</h3>
<p class="empty-desc">请先在文件管理中上传文档</p>
</div>
<!-- File Table -->
<div v-else-if="!selectedFile" class="files-table-wrapper">
<div class="table-header">
<span class="table-title">文件列表</span>
</div>
<div class="files-list">
<div
v-for="(file, index) in files"
:key="file.id"
class="file-row"
:class="{ processing: file.status === 'processing' }"
:style="{ '--delay': index * 0.03 + 's' }"
@click="handleFileClick(file)"
>
<div class="col-icon">
<div class="file-type-icon" :style="{ background: getFileBg(file.file_type) }">
<el-icon size="18" color="white">
<component :is="getFileIcon(file.file_type)" />
</el-icon>
</div>
</div>
<div class="col-name">
<span class="file-name">{{ file.filename }}</span>
<span class="file-meta">{{ formatSize(file.size) }}</span>
</div>
<div class="col-chunks">
<span v-if="fileChunks[file.id]" class="chunk-count">
{{ fileChunks[file.id] }}
</span>
<span v-else class="chunk-count empty">-</span>
</div>
<div class="col-status">
<div v-if="file.status === 'processing'" class="status-badge processing">
<el-icon class="spin" size="12"><Loading /></el-icon>
<span>分割中</span>
</div>
<div v-else-if="fileChunks[file.id]" class="status-badge success">
<el-icon size="12"><CircleCheckFilled /></el-icon>
<span>已完成</span>
</div>
<div v-else class="status-badge pending">
<el-icon size="12"><Clock /></el-icon>
<span>待分割</span>
</div>
</div>
<div class="col-action">
<el-icon><ArrowRight /></el-icon>
</div>
</div>
</div>
</div>
<!-- Split Detail View -->
<div v-else class="split-detail">
<div class="detail-header">
<el-button text @click="selectedFile = null" class="back-btn">
<el-icon><ArrowLeft /></el-icon>
<span>返回文件列表</span>
</el-button>
<div class="file-info">
<div class="file-type-icon small" :style="{ background: getFileBg(selectedFile.file_type) }">
<el-icon size="14" color="white">
<component :is="getFileIcon(selectedFile.file_type)" />
</el-icon>
</div>
<span class="file-name">{{ selectedFile.filename }}</span>
</div>
</div>
<!-- Split Config -->
<div class="config-card">
<div class="config-title">分割配置</div>
<div class="config-form">
<div class="form-row">
<div class="form-item">
<label>分割算法</label>
<el-select v-model="splitConfig.method" placeholder="选择算法">
<el-option label="Markdown 结构分割" value="markdown_structure" />
<el-option label="递归字符分割" value="recursive" />
<el-option label="Token 数量分割" value="token" />
<el-option label="代码感知分割" value="code" />
<el-option label="自定义分隔符" value="custom" />
</el-select>
</div>
</div>
<div class="form-row sliders">
<div class="form-item">
<label>块大小: {{ splitConfig.chunk_size }}</label>
<el-slider
v-model="splitConfig.chunk_size"
:min="100"
:max="2000"
:step="100"
:marks="{100: '100', 500: '500', 1000: '1k', 2000: '2k'}"
/>
</div>
<div class="form-item">
<label>重叠字符: {{ splitConfig.overlap }}</label>
<el-slider
v-model="splitConfig.overlap"
:min="0"
:max="500"
:step="50"
:marks="{0: '0', 250: '250', 500: '500'}"
/>
</div>
</div>
<div class="form-item" v-if="splitConfig.method === 'custom'">
<label>自定义分隔符</label>
<el-input v-model="splitConfig.separator" placeholder="例如: \n\n 或 || 或 ---" />
</div>
<div class="config-actions">
<el-button type="primary" @click="handleSplit" :loading="splitting" class="split-btn">
<el-icon><CaretRight /></el-icon>
开始分割
</el-button>
</div>
</div>
</div>
<!-- Chunks List -->
<div class="chunks-card" v-if="chunks.length > 0">
<div class="chunks-header">
<div class="chunks-title">
<el-icon><List /></el-icon>
<span>文本块 ({{ chunks.length }})</span>
</div>
<el-tag type="primary" effect="dark">
总计 {{ totalWords }}
</el-tag>
</div>
<div class="chunks-list" v-loading="loadingChunks">
<div
v-for="(chunk, index) in chunks"
:key="chunk.id"
class="chunk-item"
>
<div class="chunk-header">
<div class="chunk-badge">{{ index + 1 }}</div>
<el-input
v-model="chunk.name"
class="chunk-name-input"
placeholder="输入块名称"
@blur="saveChunkName(chunk)"
/>
<span class="chunk-meta">{{ chunk.word_count || 0 }} </span>
</div>
<el-input
v-model="chunk.content"
type="textarea"
:rows="4"
class="chunk-content-input"
@blur="saveChunkContent(chunk)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { fileApi, chunkApi } from '@/api'
const route = useRoute()
const router = useRouter()
const projectId = computed(() => route.params.id)
const loading = ref(false)
const loadingChunks = ref(false)
const splitting = ref(false)
const files = ref([])
const selectedFile = ref(null)
const chunks = ref([])
const fileChunks = ref({})
const splitConfig = reactive({
method: 'recursive',
chunk_size: 500,
overlap: 50,
separator: '\n\n'
})
const completedFiles = computed(() => {
return Object.keys(fileChunks.value).length
})
const processingCount = computed(() => {
return files.value.filter(f => f.status === 'processing').length
})
const totalChunks = computed(() => {
return Object.values(fileChunks.value).reduce((sum, count) => sum + count, 0)
})
const hasProcessedFiles = computed(() => {
return Object.keys(fileChunks.value).length > 0
})
const totalWords = computed(() => {
return chunks.value.reduce((sum, c) => sum + (c.word_count || 0), 0)
})
const fetchFiles = async () => {
loading.value = true
try {
const res = await fileApi.list(projectId.value)
files.value = res.data || []
// 获取每个文件的 chunk 数量
await fetchChunksCount()
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
const fetchChunksCount = async () => {
const counts = {}
for (const file of files.value) {
try {
const res = await chunkApi.list(projectId.value, { file_id: file.id })
const chunks = res.data?.chunks || []
if (chunks.length > 0) {
counts[file.id] = chunks.length
}
} catch (e) {
console.error(e)
}
}
fileChunks.value = counts
}
const fetchChunks = async () => {
if (!selectedFile.value) return
loadingChunks.value = true
try {
const res = await chunkApi.list(projectId.value, { file_id: selectedFile.value.id })
chunks.value = res.data?.chunks || []
} catch (error) {
ElMessage.error('获取分割结果失败')
} finally {
loadingChunks.value = false
}
}
const handleFileClick = (file) => {
selectedFile.value = file
fetchChunks()
}
const handleSplit = async () => {
if (!selectedFile.value) {
ElMessage.warning('请先选择文件')
return
}
splitting.value = true
try {
await chunkApi.split(projectId.value, { file_id: selectedFile.value.id, ...splitConfig })
ElMessage.success('分割任务已启动')
// 等待一下再获取结果
setTimeout(() => {
fetchChunks()
fetchFiles()
}, 2000)
} catch (error) {
ElMessage.error('分割失败')
} finally {
splitting.value = false
}
}
const saveChunkName = async (chunk) => {
try {
await chunkApi.update(projectId.value, chunk.id, { name: chunk.name })
} catch (error) {
console.error(error)
}
}
const saveChunkContent = async (chunk) => {
try {
await chunkApi.update(projectId.value, chunk.id, { content: chunk.content })
} catch (error) {
console.error(error)
}
}
const handleBatchGenerateQA = () => {
if (!hasProcessedFiles.value) {
ElMessage.warning('请先分割文件')
return
}
ElMessage.info('跳转到问答生成页面')
router.push(`/project/${projectId.value}/questions`)
}
const refreshFiles = () => {
fetchFiles()
}
const formatSize = (bytes) => {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024
i++
}
return `${bytes.toFixed(1)} ${units[i]}`
}
const getFileBg = (type) => {
const colors = {
pdf: '#ef4444',
docx: '#3b82f6',
xlsx: '#22c55e',
csv: '#f59e0b',
md: '#8b5cf6',
txt: '#6b7280',
epub: '#ec4899'
}
return colors[type] || '#6b7280'
}
const getFileIcon = (type) => {
const icons = {
pdf: 'Document',
docx: 'Document',
xlsx: 'Grid',
csv: 'Grid',
md: 'Document',
txt: 'Document',
epub: 'Book'
}
return icons[type] || 'Document'
}
onMounted(() => {
fetchFiles()
})
</script>
<style scoped>
.text-split {
--accent-cyan: #00d4ff;
--accent-cyan-dim: rgba(0, 212, 255, 0.15);
--accent-cyan-glow: rgba(0, 212, 255, 0.4);
--bg-elevated: #0f1117;
--bg-card: #161920;
--bg-hover: #1c2029;
--border-subtle: rgba(255, 255, 255, 0.08);
--border-active: rgba(0, 212, 255, 0.3);
--text-secondary: #9ca3af;
--text-muted: #6b7280;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--radius-lg: 12px;
--radius-md: 8px;
--radius-sm: 6px;
padding: 28px 32px;
}
/* Header */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 28px;
}
.header-left {
position: relative;
}
.page-title {
font-size: 28px;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, #ffffff 0%, var(--accent-cyan) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.page-subtitle {
font-size: 14px;
color: var(--text-muted);
margin: 6px 0 0;
}
.header-actions {
display: flex;
gap: 12px;
}
.refresh-btn {
background: var(--bg-card) !important;
border: 1px solid var(--border-subtle) !important;
color: var(--text-secondary) !important;
font-weight: 500;
padding: 10px 18px;
border-radius: var(--radius-md);
}
.refresh-btn:hover {
background: var(--bg-hover) !important;
border-color: var(--accent-cyan) !important;
}
.generate-btn {
background: var(--accent-cyan) !important;
border: none !important;
color: #030407 !important;
font-weight: 600;
padding: 10px 22px;
border-radius: var(--radius-md);
box-shadow: 0 0 20px var(--accent-cyan-dim);
}
.generate-btn:hover:not(:disabled) {
box-shadow: 0 0 35px var(--accent-cyan-glow);
transform: translateY(-1px);
}
.generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
position: relative;
overflow: hidden;
transition: all 0.2s ease;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
}
.stat-card.total::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
.stat-card.success::before { background: linear-gradient(90deg, #22c55e, #4ade80); }
.stat-card.processing::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.stat-card:hover {
border-color: var(--border-active);
transform: translateY(-2px);
}
.stat-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
font-size: 22px;
}
.stat-icon.total { background: rgba(139, 92, 246, 0.15); color: #a78bfa; }
.stat-icon.success { background: rgba(34, 197, 94, 0.15); color: #4ade80; }
.stat-icon.processing { background: rgba(245, 158, 11, 0.15); color: #fbbf24; }
.stat-icon.chunks { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.stat-content {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 26px;
font-weight: 700;
font-family: 'SF Mono', Monaco, monospace;
color: #fff;
}
.stat-label {
font-size: 13px;
color: var(--text-muted);
margin-top: 2px;
}
/* Content Area */
.content-area {
min-height: 400px;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
background: var(--bg-card);
border: 2px dashed var(--border-subtle);
border-radius: var(--radius-lg);
}
.empty-icon {
width: 100px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-hover);
border-radius: 50%;
margin-bottom: 24px;
border: 1px solid var(--border-subtle);
}
.empty-icon .el-icon {
color: var(--text-muted);
}
.empty-title {
font-size: 22px;
font-weight: 600;
color: #ffffff;
margin: 0 0 8px;
}
.empty-desc {
font-size: 14px;
color: var(--text-muted);
margin: 0;
}
/* Files Table */
.files-table-wrapper {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
}
.table-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
}
.table-title {
font-size: 15px;
font-weight: 600;
color: var(--text-secondary);
}
.files-list {
max-height: 500px;
overflow-y: auto;
}
.file-row {
display: grid;
grid-template-columns: 50px 1fr 80px 100px 40px;
align-items: center;
gap: 16px;
padding: 14px 20px;
border-bottom: 1px solid var(--border-subtle);
cursor: pointer;
transition: all 0.2s ease;
animation: slideIn 0.3s ease backwards;
animation-delay: var(--delay);
}
.file-row:last-child {
border-bottom: none;
}
.file-row:hover {
background: var(--bg-hover);
}
.file-row.processing {
opacity: 0.7;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
}
.col-icon {
display: flex;
justify-content: center;
}
.file-type-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
}
.col-name {
display: flex;
flex-direction: column;
min-width: 0;
}
.file-name {
font-weight: 500;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.col-chunks {
text-align: center;
}
.chunk-count {
font-size: 14px;
color: var(--text-secondary);
}
.chunk-count.empty {
color: var(--text-muted);
}
.col-status {
display: flex;
justify-content: center;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.status-badge.processing {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.status-badge.success {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.status-badge.pending {
background: rgba(107, 114, 128, 0.15);
color: var(--text-muted);
}
.col-action {
color: var(--text-muted);
display: flex;
justify-content: center;
}
.file-row:hover .col-action {
color: var(--accent-cyan);
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Split Detail */
.split-detail {
display: flex;
flex-direction: column;
gap: 20px;
}
.detail-header {
display: flex;
align-items: center;
gap: 16px;
}
.back-btn {
color: var(--text-secondary) !important;
font-weight: 500;
}
.back-btn:hover {
color: var(--accent-cyan) !important;
}
.file-info {
display: flex;
align-items: center;
gap: 10px;
}
.file-type-icon.small {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
}
.file-info .file-name {
font-weight: 600;
}
/* Config Card */
.config-card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 24px;
}
.config-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
color: var(--text-secondary);
}
.config-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-item label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.config-actions {
display: flex;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
}
.split-btn {
background: var(--accent-cyan) !important;
border: none !important;
color: #030407 !important;
font-weight: 600;
padding: 12px 28px;
border-radius: var(--radius-md);
}
/* Chunks Card */
.chunks-card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
}
.chunks-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
}
.chunks-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.chunks-list {
max-height: 500px;
overflow-y: auto;
}
.chunk-item {
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
}
.chunk-item:last-child {
border-bottom: none;
}
.chunk-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.chunk-badge {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--accent-cyan), #06b6d4);
color: white;
border-radius: 50%;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.chunk-name-input {
flex: 1;
max-width: 300px;
}
.chunk-name-input :deep(.el-input__wrapper) {
background: var(--bg-hover);
border: 1px solid var(--border-subtle);
box-shadow: none;
}
.chunk-name-input :deep(.el-input__inner) {
color: #fff;
font-weight: 500;
}
.chunk-meta {
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
}
.chunk-content-input :deep(.el-textarea__inner) {
background: var(--bg-hover);
border: 1px solid var(--border-subtle);
box-shadow: none;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
.chunk-content-input :deep(.el-textarea__inner:focus) {
border-color: var(--accent-cyan);
}
/* Responsive */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.file-row {
grid-template-columns: 40px 1fr 60px;
}
.col-chunks,
.col-status,
.col-action {
display: none;
}
}
</style>