471 lines
11 KiB
Vue
471 lines
11 KiB
Vue
|
|
<template>
|
||
|
|
<div class="text-split">
|
||
|
|
<!-- Page Header -->
|
||
|
|
<div class="page-header">
|
||
|
|
<div class="header-left">
|
||
|
|
<h2>文本分割</h2>
|
||
|
|
<p class="header-desc">将文档内容智能分割为文本块</p>
|
||
|
|
</div>
|
||
|
|
<div class="header-actions">
|
||
|
|
<el-button @click="refreshChunks" class="action-btn">
|
||
|
|
<el-icon><Refresh /></el-icon>
|
||
|
|
刷新
|
||
|
|
</el-button>
|
||
|
|
<el-button type="primary" @click="showSplitDialog = true" class="action-btn">
|
||
|
|
<el-icon><Plus /></el-icon>
|
||
|
|
新建分割
|
||
|
|
</el-button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Split Config Card -->
|
||
|
|
<div class="config-card" v-if="selectedFile">
|
||
|
|
<div class="config-header">
|
||
|
|
<div class="config-title">
|
||
|
|
<div class="file-badge">
|
||
|
|
<el-icon><Document /></el-icon>
|
||
|
|
</div>
|
||
|
|
<span>{{ selectedFile.filename }}</span>
|
||
|
|
</div>
|
||
|
|
</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-action">
|
||
|
|
<el-button type="primary" size="large" @click="handleSplit" :loading="splitting" class="start-btn">
|
||
|
|
<el-icon><CaretRight /></el-icon>
|
||
|
|
开始分割
|
||
|
|
</el-button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-else class="empty-state">
|
||
|
|
<div class="empty-illustration">
|
||
|
|
<div class="ring"></div>
|
||
|
|
<el-icon size="56"><Document /></el-icon>
|
||
|
|
</div>
|
||
|
|
<h3>未选择文件</h3>
|
||
|
|
<p>请从文件管理中选择一个文件开始分割</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Chunk List -->
|
||
|
|
<div class="chunks-card" v-if="selectedFile && 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="chunk-list" v-loading="loading">
|
||
|
|
<div
|
||
|
|
v-for="(chunk, index) in chunks"
|
||
|
|
:key="chunk.id"
|
||
|
|
class="chunk-item"
|
||
|
|
:style="{ '--delay': index * 0.03 + 's' }"
|
||
|
|
>
|
||
|
|
<div class="chunk-header">
|
||
|
|
<div class="chunk-badge">{{ index + 1 }}</div>
|
||
|
|
<span class="chunk-title">{{ chunk.name || '未命名' }}</span>
|
||
|
|
<span class="chunk-meta">{{ chunk.word_count || 0 }} 字</span>
|
||
|
|
</div>
|
||
|
|
<div class="chunk-content">{{ chunk.content }}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Select File Dialog -->
|
||
|
|
<el-dialog v-model="showSplitDialog" title="选择文件" width="500px" class="select-dialog">
|
||
|
|
<el-select v-model="selectedFileId" placeholder="选择要分割的文件" style="width: 100%" size="large">
|
||
|
|
<el-option
|
||
|
|
v-for="file in files"
|
||
|
|
:key="file.id"
|
||
|
|
:label="file.filename"
|
||
|
|
:value="file.id"
|
||
|
|
>
|
||
|
|
<div class="file-option">
|
||
|
|
<el-icon><Document /></el-icon>
|
||
|
|
<span>{{ file.filename }}</span>
|
||
|
|
</div>
|
||
|
|
</el-option>
|
||
|
|
</el-select>
|
||
|
|
<template #footer>
|
||
|
|
<el-button @click="showSplitDialog = false">取消</el-button>
|
||
|
|
<el-button type="primary" @click="confirmSelectFile">确认</el-button>
|
||
|
|
</template>
|
||
|
|
</el-dialog>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup>
|
||
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||
|
|
import { useRoute } from 'vue-router'
|
||
|
|
import { ElMessage } from 'element-plus'
|
||
|
|
import { fileApi, chunkApi } from '@/api'
|
||
|
|
|
||
|
|
const route = useRoute()
|
||
|
|
const projectId = computed(() => route.params.id)
|
||
|
|
|
||
|
|
const loading = ref(false)
|
||
|
|
const splitting = ref(false)
|
||
|
|
const files = ref([])
|
||
|
|
const chunks = ref([])
|
||
|
|
const selectedFileId = ref('')
|
||
|
|
const selectedFile = ref(null)
|
||
|
|
const showSplitDialog = ref(false)
|
||
|
|
|
||
|
|
const splitConfig = reactive({
|
||
|
|
method: 'recursive',
|
||
|
|
chunk_size: 500,
|
||
|
|
overlap: 50,
|
||
|
|
separator: '\n\n'
|
||
|
|
})
|
||
|
|
|
||
|
|
const totalWords = computed(() => {
|
||
|
|
return chunks.value.reduce((sum, c) => sum + (c.word_count || 0), 0)
|
||
|
|
})
|
||
|
|
|
||
|
|
const fetchFiles = async () => {
|
||
|
|
try {
|
||
|
|
const res = await fileApi.list(projectId.value)
|
||
|
|
files.value = res.data.files || []
|
||
|
|
} catch (error) {
|
||
|
|
console.error(error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const fetchChunks = async () => {
|
||
|
|
if (!selectedFile.value) return
|
||
|
|
loading.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 {
|
||
|
|
loading.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const confirmSelectFile = () => {
|
||
|
|
selectedFile.value = files.value.find(f => f.id === selectedFileId.value)
|
||
|
|
showSplitDialog.value = false
|
||
|
|
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('分割任务已启动')
|
||
|
|
fetchChunks()
|
||
|
|
} catch (error) {
|
||
|
|
ElMessage.error('分割失败')
|
||
|
|
} finally {
|
||
|
|
splitting.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const refreshChunks = () => fetchChunks()
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
fetchFiles()
|
||
|
|
const fileId = route.query.fileId
|
||
|
|
if (fileId) {
|
||
|
|
selectedFileId.value = fileId
|
||
|
|
setTimeout(() => {
|
||
|
|
selectedFile.value = files.value.find(f => f.id === fileId)
|
||
|
|
if (selectedFile.value) fetchChunks()
|
||
|
|
}, 500)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.text-split {
|
||
|
|
padding: 32px;
|
||
|
|
max-width: 1200px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Header */
|
||
|
|
.page-header {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
margin-bottom: 28px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.header-left h2 {
|
||
|
|
font-size: 24px;
|
||
|
|
font-weight: 600;
|
||
|
|
margin-bottom: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.header-desc {
|
||
|
|
font-size: 14px;
|
||
|
|
color: var(--text-tertiary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.header-actions {
|
||
|
|
display: flex;
|
||
|
|
gap: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.action-btn {
|
||
|
|
padding: 10px 18px;
|
||
|
|
border-radius: var(--radius-md);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Config Card */
|
||
|
|
.config-card {
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
border: 1px solid var(--border-subtle);
|
||
|
|
border-radius: var(--radius-lg);
|
||
|
|
padding: 24px;
|
||
|
|
margin-bottom: 24px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.config-header {
|
||
|
|
margin-bottom: 20px;
|
||
|
|
padding-bottom: 20px;
|
||
|
|
border-bottom: 1px solid var(--border-subtle);
|
||
|
|
}
|
||
|
|
|
||
|
|
.config-title {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 12px;
|
||
|
|
font-size: 15px;
|
||
|
|
font-weight: 600;
|
||
|
|
}
|
||
|
|
|
||
|
|
.file-badge {
|
||
|
|
width: 36px;
|
||
|
|
height: 36px;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
background: var(--accent-primary-muted);
|
||
|
|
border-radius: var(--radius-md);
|
||
|
|
color: var(--accent-primary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.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-action {
|
||
|
|
display: flex;
|
||
|
|
justify-content: flex-end;
|
||
|
|
padding-top: 20px;
|
||
|
|
border-top: 1px solid var(--border-subtle);
|
||
|
|
}
|
||
|
|
|
||
|
|
.start-btn {
|
||
|
|
padding: 12px 28px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Empty State */
|
||
|
|
.empty-state {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
padding: 80px 20px;
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
border: 1px dashed var(--border-default);
|
||
|
|
border-radius: var(--radius-lg);
|
||
|
|
text-align: center;
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-illustration {
|
||
|
|
position: relative;
|
||
|
|
width: 100px;
|
||
|
|
height: 100px;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
margin-bottom: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ring {
|
||
|
|
position: absolute;
|
||
|
|
inset: 0;
|
||
|
|
border: 2px dashed var(--border-default);
|
||
|
|
border-radius: 50%;
|
||
|
|
animation: spin 15s linear infinite;
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes spin {
|
||
|
|
from { transform: rotate(0deg); }
|
||
|
|
to { transform: rotate(360deg); }
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-illustration .el-icon {
|
||
|
|
color: var(--text-muted);
|
||
|
|
z-index: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-state h3 {
|
||
|
|
font-size: 18px;
|
||
|
|
margin-bottom: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty-state p {
|
||
|
|
color: var(--text-tertiary);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Chunks Card */
|
||
|
|
.chunks-card {
|
||
|
|
background: var(--bg-secondary);
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
.chunk-list {
|
||
|
|
max-height: 600px;
|
||
|
|
overflow-y: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.chunk-item {
|
||
|
|
padding: 20px;
|
||
|
|
border-bottom: 1px solid var(--border-subtle);
|
||
|
|
transition: background var(--transition-fast);
|
||
|
|
animation: slideIn 0.3s ease backwards;
|
||
|
|
animation-delay: var(--delay);
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes slideIn {
|
||
|
|
from {
|
||
|
|
opacity: 0;
|
||
|
|
transform: translateX(-10px);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
.chunk-item:last-child {
|
||
|
|
border-bottom: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.chunk-item:hover {
|
||
|
|
background: var(--bg-hover);
|
||
|
|
}
|
||
|
|
|
||
|
|
.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: var(--accent-gradient);
|
||
|
|
color: white;
|
||
|
|
border-radius: 50%;
|
||
|
|
font-size: 12px;
|
||
|
|
font-weight: 600;
|
||
|
|
}
|
||
|
|
|
||
|
|
.chunk-title {
|
||
|
|
font-weight: 600;
|
||
|
|
flex: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.chunk-meta {
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.chunk-content {
|
||
|
|
font-size: 13px;
|
||
|
|
color: var(--text-secondary);
|
||
|
|
line-height: 1.7;
|
||
|
|
white-space: pre-wrap;
|
||
|
|
max-height: 120px;
|
||
|
|
overflow-y: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* File Option */
|
||
|
|
.file-option {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 10px;
|
||
|
|
}
|
||
|
|
</style>
|