This commit is contained in:
amb
2026-09-01 20:51:46 +08:00
parent 70ece67910
commit b80161972a
18 changed files with 1878 additions and 359 deletions
+10 -1
View File
@@ -21,7 +21,16 @@ apiClient.interceptors.response.use((response) => response, (error) => {
return Promise.reject(error);
}
// 业务错误:显示服务端中文文案
const message = data?.message || '请求失败';
let message = data?.message || '请求失败';
// 验证错误:拼接具体字段错误
if (data?.detail && Array.isArray(data.detail)) {
const fieldErrors = data.detail.map((e) => {
const field = e.loc?.join('.') || '';
return field ? `${field}: ${e.msg}` : e.msg;
}).join('\n');
if (fieldErrors)
message = fieldErrors;
}
ElMessage.error(message);
}
else if (error.request) {
+9 -1
View File
@@ -27,7 +27,15 @@ apiClient.interceptors.response.use(
}
// 业务错误:显示服务端中文文案
const message = data?.message || '请求失败'
let message = data?.message || '请求失败'
// 验证错误:拼接具体字段错误
if (data?.detail && Array.isArray(data.detail)) {
const fieldErrors = data.detail.map((e: any) => {
const field = e.loc?.join('.') || ''
return field ? `${field}: ${e.msg}` : e.msg
}).join('\n')
if (fieldErrors) message = fieldErrors
}
ElMessage.error(message)
} else if (error.request) {
ElMessage.error('网络错误,请检查连接。')
+289 -53
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import apiClient from '@/api/client'
@@ -9,12 +9,32 @@ const kbId = route.params.id as string
const kb = ref<any>(null)
const docs = ref<any[]>([])
const categories = ref<any[]>([])
const loading = ref(false)
const uploading = ref(false)
const aiUrl = ref('')
const selectedCategoryId = ref<string | null>(null)
const selectedCategoryPath = ref<string | null>(null)
// 文本内容对话框
const showTextDialog = ref(false)
const textForm = ref({
title: '',
content: '',
content_format: 'markdown',
category_id: null as string | null,
})
const textLoading = ref(false)
// 目录管理
const showCatDialog = ref(false)
const catForm = ref({ name: '', parent_id: null as string | null, is_folder: true })
const catLoading = ref(false)
const editingCatId = ref<string | null>(null)
onMounted(async () => {
await loadKb()
await loadCategories()
await loadDocs()
await loadLink()
})
@@ -26,10 +46,21 @@ async function loadKb() {
} catch { /* ignore */ }
}
async function loadCategories() {
try {
const { data } = await apiClient.get(`/knowledge-bases/${kbId}/categories/tree`)
categories.value = data
} catch { /* ignore */ }
}
async function loadDocs() {
loading.value = true
try {
const { data } = await apiClient.get(`/documents?kb_id=${kbId}`)
let url = `/documents?kb_id=${kbId}`
if (selectedCategoryId.value) {
url += `&category_id=${selectedCategoryId.value}`
}
const { data } = await apiClient.get(url)
docs.value = data.items
} catch { /* ignore */ }
loading.value = false
@@ -42,27 +73,69 @@ async function loadLink() {
} catch { /* ignore */ }
}
function selectCategory(cat: any) {
selectedCategoryId.value = cat.id
selectedCategoryPath.value = cat.path
loadDocs()
}
function clearCategoryFilter() {
selectedCategoryId.value = null
selectedCategoryPath.value = null
loadDocs()
}
// 上传文档
async function handleUpload(options: any) {
uploading.value = true
try {
const formData = new FormData()
formData.append('kb_id', kbId)
formData.append('file', options.file)
if (selectedCategoryId.value) {
formData.append('category_id', selectedCategoryId.value)
}
await apiClient.post('/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
ElMessage.success('文档上传成功!')
loadDocs()
loadCategories()
} catch { /* ignore */ }
uploading.value = false
}
// 创建文本内容
async function handleCreateText() {
if (!textForm.value.title.trim() || !textForm.value.content.trim()) {
ElMessage.warning('请输入标题和内容。')
return
}
textLoading.value = true
try {
await apiClient.post('/documents/create-text', {
kb_id: kbId,
title: textForm.value.title,
content: textForm.value.content,
content_format: textForm.value.content_format,
category_id: selectedCategoryId.value || textForm.value.category_id,
})
ElMessage.success('文本内容已创建!')
showTextDialog.value = false
textForm.value = { title: '', content: '', content_format: 'markdown', category_id: null }
loadDocs()
loadCategories()
} catch { /* ignore */ }
textLoading.value = false
}
async function handleDeleteDoc(doc: any) {
try {
await ElMessageBox.confirm(`确定删除文档${doc.original_filename}」?`, '确认删除', { type: 'warning' })
await ElMessageBox.confirm(`确定删除「${doc.original_filename}」?`, '确认删除', { type: 'warning' })
await apiClient.delete(`/documents/${doc.id}`)
ElMessage.success('已删除。')
loadDocs()
loadCategories()
} catch { /* ignore */ }
}
@@ -81,6 +154,51 @@ async function handleCopyLink() {
} catch { /* ignore */ }
}
// 目录管理
function openAddCategory(parentId: string | null = null) {
editingCatId.value = null
catForm.value = { name: '', parent_id: parentId, is_folder: true }
showCatDialog.value = true
}
function openEditCategory(cat: any) {
editingCatId.value = cat.id
catForm.value = { name: cat.name, parent_id: cat.parent_id || null, is_folder: cat.is_folder }
showCatDialog.value = true
}
async function handleSaveCategory() {
if (!catForm.value.name.trim()) {
ElMessage.warning('请输入目录名称。')
return
}
catLoading.value = true
try {
if (editingCatId.value) {
await apiClient.put(`/knowledge-bases/${kbId}/categories/${editingCatId.value}`, catForm.value)
ElMessage.success('目录已更新。')
} else {
await apiClient.post(`/knowledge-bases/${kbId}/categories`, catForm.value)
ElMessage.success('目录已创建。')
}
showCatDialog.value = false
loadCategories()
} catch { /* ignore */ }
catLoading.value = false
}
async function handleDeleteCategory(cat: any) {
try {
await ElMessageBox.confirm(`确定删除目录「${cat.name}」及其所有子目录?`, '确认删除', { type: 'warning' })
await apiClient.delete(`/knowledge-bases/${kbId}/categories/${cat.id}`)
ElMessage.success('已删除。')
if (selectedCategoryId.value === cat.id) {
clearCategoryFilter()
}
loadCategories()
} catch { /* ignore */ }
}
async function handleRegenerate() {
try {
await ElMessageBox.confirm('重新生成链接后,旧链接将立即失效。确定继续?', '确认', { type: 'warning' })
@@ -101,64 +219,182 @@ function statusType(status: string) {
if (status === 'FAILED') return 'danger'
return 'warning'
}
// 递归获取所有叶子节点(用于文本对话框的分类选择器)
function flattenCategories(nodes: any[], level = 0): any[] {
const result: any[] = []
for (const node of nodes) {
result.push({ ...node, level })
if (node.children?.length) {
result.push(...flattenCategories(node.children, level + 1))
}
}
return result
}
const flatCategories = computed(() => flattenCategories(categories.value))
</script>
<template>
<div v-if="kb">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px">
<h1 style="margin: 0">{{ kb.name }}</h1>
<el-button type="primary" @click="handleCopyLink">复制 AI 链接</el-button>
<div v-if="kb" style="display: flex; gap: 20px">
<!-- 左侧目录树 -->
<div style="width: 280px; flex-shrink: 0">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px">
<h3 style="margin: 0">目录</h3>
<el-button size="small" @click="openAddCategory(null)">+ 新建</el-button>
</div>
<el-tree
:data="categories"
node-key="id"
default-expand-all
:expand-on-click-node="false"
>
<template #default="{ node, data }">
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%; padding: 4px 0">
<span
:style="{ cursor: 'pointer', color: selectedCategoryId === data.id ? '#409eff' : '#333', fontWeight: selectedCategoryId === data.id ? 'bold' : 'normal' }"
@click="selectCategory(data)"
>
{{ data.name }}
<span v-if="data.doc_count > 0" style="color: #999; font-size: 0.85em">({{ data.doc_count }})</span>
</span>
<span>
<el-button size="small" text @click.stop="openAddCategory(data.id)">+</el-button>
<el-button size="small" text @click.stop="openEditCategory(data)"></el-button>
<el-button size="small" text type="danger" @click.stop="handleDeleteCategory(data)">×</el-button>
</span>
</div>
</template>
</el-tree>
<!-- 全部文档 -->
<div
style="margin-top: 10px; padding: 8px 10px; cursor: pointer; border-radius: 4px; background: #f5f7fa"
:style="{ background: !selectedCategoryId ? '#ecf5ff' : '#f5f7fa' }"
@click="clearCategoryFilter"
>
全部文档
</div>
</div>
<el-card style="margin-bottom: 20px">
<el-descriptions :column="2" border>
<el-descriptions-item label="描述">{{ kb.description || '无' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="kb.enabled ? 'success' : 'danger'">{{ kb.enabled ? '启用' : '禁用' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="AI 链接">
<div style="display: flex; align-items: center; gap: 8px">
<el-input :model-value="aiUrl" readonly size="small" style="flex: 1" />
<el-button size="small" @click="handleRegenerate">重新生成</el-button>
<!-- 右侧文档列表 -->
<div style="flex: 1">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px">
<h1 style="margin: 0">{{ kb.name }}</h1>
<el-button type="primary" @click="handleCopyLink">复制 AI 链接</el-button>
</div>
<el-card style="margin-bottom: 20px">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="描述">{{ kb.description || '无' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="kb.enabled ? 'success' : 'danger'">{{ kb.enabled ? '启用' : '禁用' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card>
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center">
<span>
文档列表
<el-tag v-if="selectedCategoryPath" closable @close="clearCategoryFilter" style="margin-left: 8px">
{{ selectedCategoryPath }}
</el-tag>
</span>
<div style="display: flex; gap: 8px">
<el-button @click="showTextDialog = true">添加文本</el-button>
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf">
<el-button type="primary" :loading="uploading">上传文档</el-button>
</el-upload>
</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="文档数">{{ docs.length }}</el-descriptions-item>
</el-descriptions>
</el-card>
</template>
<el-card>
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center">
<span>文档列表</span>
<el-upload
:show-file-list="false"
:http-request="handleUpload"
accept=".docx,.pdf"
>
<el-button type="primary" :loading="uploading">上传文档</el-button>
</el-upload>
</div>
<el-table :data="docs" v-loading="loading" style="width: 100%">
<el-table-column prop="original_filename" label="标题/文件名" min-width="200" show-overflow-tooltip />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">{{ row.file_ext }}</template>
</el-table-column>
<el-table-column label="大小" width="100" align="center">
<template #default="{ row }">{{ formatSize(row.file_size) }}</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
<el-table-column prop="keywords" label="关键词" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button size="small" @click="handleReprocess(row)">重新解析</el-button>
<el-button size="small" type="danger" @click="handleDeleteDoc(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- 文本内容对话框 -->
<el-dialog v-model="showTextDialog" title="添加文本内容" width="700px">
<el-form :model="textForm" label-position="top">
<el-form-item label="标题" required>
<el-input v-model="textForm.title" placeholder="文档标题" />
</el-form-item>
<el-form-item label="所属目录">
<el-select v-model="textForm.category_id" placeholder="选择目录(可选)" clearable style="width: 100%">
<el-option
v-for="cat in flatCategories"
:key="cat.id"
:label="cat.name"
:value="cat.id"
>
<span :style="{ paddingLeft: cat.level * 20 + 'px' }">{{ cat.name }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="格式">
<el-radio-group v-model="textForm.content_format">
<el-radio value="markdown">Markdown</el-radio>
<el-radio value="text">纯文本</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="内容" required>
<el-input
v-model="textForm.content"
type="textarea"
:rows="12"
placeholder="输入文本内容(支持 Markdown 格式)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showTextDialog = false">取消</el-button>
<el-button type="primary" :loading="textLoading" @click="handleCreateText">创建</el-button>
</template>
</el-dialog>
<el-table :data="docs" v-loading="loading" style="width: 100%">
<el-table-column prop="original_filename" label="文件名" min-width="200" show-overflow-tooltip />
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="大小" width="100" align="center">
<template #default="{ row }">{{ formatSize(row.file_size) }}</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
<el-table-column prop="keywords" label="关键词" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button size="small" @click="handleReprocess(row)">重新解析</el-button>
<el-button size="small" type="danger" @click="handleDeleteDoc(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 目录管理对话框 -->
<el-dialog v-model="showCatDialog" :title="editingCatId ? '编辑目录' : '新建目录'" width="400px">
<el-form :model="catForm" label-position="top">
<el-form-item label="目录名称" required>
<el-input v-model="catForm.name" placeholder="如:公司基本信息" />
</el-form-item>
<el-form-item label="类型">
<el-radio-group v-model="catForm.is_folder">
<el-radio :value="true">文件夹可包含子目录</el-radio>
<el-radio :value="false">叶子分类存放文档</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCatDialog = false">取消</el-button>
<el-button type="primary" :loading="catLoading" @click="handleSaveCategory">
{{ editingCatId ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import apiClient from '@/api/client'
@@ -35,8 +36,6 @@ async function handleRegister() {
loading.value = false
}
}
import { ElMessage } from 'element-plus'
</script>
<template>
+1 -1
View File
@@ -1,5 +1,6 @@
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useUserStore } from '@/stores/user';
import apiClient from '@/api/client';
const router = useRouter();
@@ -33,7 +34,6 @@ async function handleRegister() {
loading.value = false;
}
}
import { ElMessage } from 'element-plus';
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
const __VLS_ctx = {};
let __VLS_components;