284 lines
8.7 KiB
Vue
284 lines
8.7 KiB
Vue
<template>
|
||
<div class="dd-page">
|
||
<div class="dd-head">
|
||
<el-button link @click="$router.push('/datasets')">
|
||
<el-icon><ArrowLeft /></el-icon> 返回
|
||
</el-button>
|
||
<span class="dd-title">{{ datasetName }}</span>
|
||
</div>
|
||
|
||
<el-upload class="dd-upload" drag :show-file-list="false" :http-request="doUpload" :disabled="uploading"
|
||
accept=".txt,.md,.pdf,.docx,.doc,.html,.htm">
|
||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||
<div class="el-upload__text">拖拽文件到此处,或 <em>点击上传</em>(txt/md/pdf/docx/html)</div>
|
||
</el-upload>
|
||
|
||
<el-table :data="documents" v-loading="loading" empty-text="暂无文档">
|
||
<el-table-column prop="filename" label="文件名" min-width="200" show-overflow-tooltip />
|
||
<el-table-column label="大小" width="100">
|
||
<template #default="{ row }">{{ formatSize(row.file_size) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tooltip :content="row.error_msg || ''" placement="top">
|
||
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="chunk_count" label="分块数" width="90" />
|
||
<el-table-column prop="created_at" label="上传时间" width="170" />
|
||
<el-table-column label="操作" width="240" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" @click="openChunks(row)">分块</el-button>
|
||
<el-button v-if="row.status === 3" link type="warning" @click="retry(row)">重试</el-button>
|
||
<el-button link type="primary" @click="reembed(row)">重新向量化</el-button>
|
||
<el-button link type="danger" @click="remove(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-pagination class="dd-page-bar" layout="total, prev, pager, next" :total="total"
|
||
:page-size="pageSize" v-model:current-page="page" @current-change="load" />
|
||
|
||
<el-drawer v-model="chunkDrawer" :title="chunkDrawerTitle" size="55%">
|
||
<div class="chunk-toolbar">
|
||
<el-input v-model="chunkKeyword" placeholder="搜索分块内容" clearable style="width: 240px" @change="loadChunks" />
|
||
</div>
|
||
<el-table :data="chunks" v-loading="chunkLoading" size="small">
|
||
<el-table-column type="index" label="#" width="50" />
|
||
<el-table-column label="内容" min-width="300">
|
||
<template #default="{ row }">
|
||
<div class="chunk-content">{{ row.content }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="110" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" @click="editChunk(row)">编辑</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-pagination class="dd-page-bar" layout="total, prev, pager, next" :total="chunkTotal"
|
||
:page-size="chunkPageSize" v-model:current-page="chunkPage" @current-change="loadChunks" />
|
||
</el-drawer>
|
||
|
||
<el-dialog v-model="editDialog" title="编辑分块" width="640px">
|
||
<el-input v-model="editContent" type="textarea" :rows="10" />
|
||
<div class="edit-tip">保存后重新分词并向量化</div>
|
||
<template #footer>
|
||
<el-button @click="editDialog = false">取消</el-button>
|
||
<el-button type="primary" :loading="editSaving" @click="saveChunk">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { ArrowLeft, UploadFilled } from '@element-plus/icons-vue'
|
||
import { listDatasets } from '../api/dataset.js'
|
||
import { listDocuments, uploadDocument, deleteDocument, reembedDocument, retryParseTask } from '../api/document.js'
|
||
import { listChunks, updateChunk } from '../api/chunk.js'
|
||
|
||
const route = useRoute()
|
||
const datasetId = computed(() => Number(route.params.id))
|
||
const datasetName = ref('')
|
||
|
||
const documents = ref([])
|
||
const total = ref(0)
|
||
const page = ref(1)
|
||
const pageSize = 20
|
||
const loading = ref(false)
|
||
const uploading = ref(false)
|
||
|
||
const chunkDrawer = ref(false)
|
||
const chunkDrawerTitle = ref('')
|
||
const chunks = ref([])
|
||
const chunkTotal = ref(0)
|
||
const chunkPage = ref(1)
|
||
const chunkPageSize = 20
|
||
const chunkLoading = ref(false)
|
||
const chunkKeyword = ref('')
|
||
let currentDoc = null
|
||
|
||
const editDialog = ref(false)
|
||
const editContent = ref('')
|
||
const editSaving = ref(false)
|
||
let editingChunk = null
|
||
|
||
const statusMap = {
|
||
0: { text: '待处理', type: 'info' },
|
||
1: { text: '解析中', type: 'warning' },
|
||
2: { text: '已完成', type: 'success' },
|
||
3: { text: '失败', type: 'danger' }
|
||
}
|
||
|
||
function statusText(s) { return (statusMap[s] || {}).text || s }
|
||
function statusType(s) { return (statusMap[s] || {}).type || 'info' }
|
||
|
||
function formatSize(bytes) {
|
||
if (!bytes) return '-'
|
||
if (bytes < 1024) return bytes + ' B'
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const d = await listDocuments({ dataset_id: datasetId.value, page: page.value, page_size: pageSize })
|
||
documents.value = d.list || []
|
||
total.value = d.total || 0
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function doUpload({ file }) {
|
||
uploading.value = true
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('dataset_id', datasetId.value)
|
||
formData.append('file', file)
|
||
await uploadDocument(formData)
|
||
ElMessage.success('上传成功,正在解析')
|
||
await load()
|
||
startPolling()
|
||
} finally {
|
||
uploading.value = false
|
||
}
|
||
}
|
||
|
||
// 有待处理/解析中的文档时轮询刷新状态,全部完成即停止
|
||
let pollTimer = null
|
||
async function startPolling() {
|
||
stopPolling()
|
||
pollTimer = setInterval(async () => {
|
||
await load()
|
||
if (!documents.value.some(d => d.status === 0 || d.status === 1)) stopPolling()
|
||
}, 3000)
|
||
}
|
||
function stopPolling() {
|
||
if (pollTimer) {
|
||
clearInterval(pollTimer)
|
||
pollTimer = null
|
||
}
|
||
}
|
||
onMounted(async () => {
|
||
try {
|
||
const dsRes = await listDatasets()
|
||
const ds = dsRes && dsRes.list ? dsRes.list : []
|
||
const cur = ds.find(x => x.id === datasetId.value)
|
||
datasetName.value = cur ? cur.name : '数据集'
|
||
} catch { /* 忽略 */ }
|
||
await load()
|
||
if (documents.value.some(d => d.status === 0 || d.status === 1)) startPolling()
|
||
})
|
||
onUnmounted(stopPolling)
|
||
|
||
async function remove(row) {
|
||
try {
|
||
await ElMessageBox.confirm(`删除文档「${row.filename}」?将同时删除分块、向量与全文索引。`, '删除确认', { type: 'warning' })
|
||
} catch {
|
||
return
|
||
}
|
||
await deleteDocument(row.id)
|
||
ElMessage.success('已删除')
|
||
await load()
|
||
}
|
||
|
||
async function retry(row) {
|
||
await retryParseTask(row.id)
|
||
ElMessage.success('已重新入队')
|
||
await load()
|
||
}
|
||
|
||
async function reembed(row) {
|
||
try {
|
||
await ElMessageBox.confirm(`重新向量化「${row.filename}」的全部分块?`, '确认', { type: 'info' })
|
||
} catch {
|
||
return
|
||
}
|
||
await reembedDocument(row.id)
|
||
ElMessage.success('重新向量化完成')
|
||
}
|
||
|
||
async function openChunks(row) {
|
||
currentDoc = row
|
||
chunkDrawerTitle.value = `分块列表 - ${row.filename}`
|
||
chunkDrawer.value = true
|
||
chunkPage.value = 1
|
||
chunkKeyword.value = ''
|
||
await loadChunks()
|
||
}
|
||
|
||
async function loadChunks() {
|
||
if (!currentDoc) return
|
||
chunkLoading.value = true
|
||
try {
|
||
const d = await listChunks({ document_id: currentDoc.id, page: chunkPage.value, page_size: chunkPageSize })
|
||
chunks.value = d.list
|
||
chunkTotal.value = d.total
|
||
} finally {
|
||
chunkLoading.value = false
|
||
}
|
||
}
|
||
|
||
function editChunk(row) {
|
||
editingChunk = row
|
||
editContent.value = row.content
|
||
editDialog.value = true
|
||
}
|
||
|
||
async function saveChunk() {
|
||
if (!editContent.value.trim()) {
|
||
ElMessage.warning('内容不能为空')
|
||
return
|
||
}
|
||
editSaving.value = true
|
||
try {
|
||
await updateChunk({ id: editingChunk.id, content: editContent.value })
|
||
ElMessage.success('已保存并重新向量化')
|
||
editDialog.value = false
|
||
await loadChunks()
|
||
} finally {
|
||
editSaving.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.dd-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.dd-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
.dd-upload {
|
||
margin-bottom: 12px;
|
||
}
|
||
.dd-page-bar {
|
||
margin-top: 10px;
|
||
justify-content: flex-end;
|
||
}
|
||
.chunk-toolbar {
|
||
margin-bottom: 10px;
|
||
}
|
||
.chunk-content {
|
||
max-height: 60px;
|
||
overflow: hidden;
|
||
font-size: 13px;
|
||
color: #606266;
|
||
line-height: 1.6;
|
||
}
|
||
.edit-tip {
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
</style>
|