Files
amb_rag/frontend/src/views/KbDetail.vue
T
2026-09-02 18:04:41 +08:00

700 lines
22 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import apiClient from '@/api/client'
import { copyToClipboard } from '@/utils/clipboard'
const route = useRoute()
const kbId = route.params.id as string
const isMobile = ref(window.innerWidth <= 768)
const showMobileSidebar = ref(false)
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth <= 768
if (!isMobile.value) showMobileSidebar.value = false
})
const kb = ref<any>(null)
const docs = ref<any[]>([])
const categories = ref<any[]>([])
const allCategoriesFlat = 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)
// 编辑文档分类
const editingDocId = ref<string | null>(null)
onMounted(async () => {
await loadKb()
await loadCategories()
await loadDocs()
await loadLink()
})
async function loadKb() {
try {
const { data } = await apiClient.get(`/knowledge-bases/${kbId}`)
kb.value = data
} catch { /* ignore */ }
}
async function loadCategories() {
try {
const { data } = await apiClient.get(`/knowledge-bases/${kbId}/categories/tree`)
categories.value = data
// 同时加载平铺列表
const { data: flat } = await apiClient.get(`/knowledge-bases/${kbId}/categories`)
allCategoriesFlat.value = flat
} catch { /* ignore */ }
}
async function loadDocs() {
loading.value = true
try {
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
}
// 链接有效期
const expiryOptions = [
{ label: '10 分钟', value: 10 },
{ label: '30 分钟', value: 30 },
{ label: '1 小时', value: 60 },
{ label: '3 小时', value: 180 },
{ label: '24 小时', value: 1440 },
{ label: '长期有效', value: 0 },
]
const linkExpiry = ref<{ expires_at: string | null; is_expired: boolean }>({
expires_at: null,
is_expired: false,
})
const expiryText = computed(() => {
if (linkExpiry.value.is_expired) return '⛔ 已失效'
if (!linkExpiry.value.expires_at) return '♾️ 长期有效'
return `⏱️ 有效期至 ${linkExpiry.value.expires_at}`
})
async function loadLink() {
try {
const { data } = await apiClient.get(`/knowledge-bases/${kbId}/link`)
aiUrl.value = `${window.location.origin}/k/${data.token}`
linkExpiry.value = {
expires_at: data.expires_at,
is_expired: data.is_expired,
}
} catch { /* ignore */ }
}
// 设置链接有效期(0 = 长期有效,需二次确认)
async function handleExpiryChange(val: number) {
if (val === 0) {
try {
await ElMessageBox.confirm(
'长期有效的链接一旦泄露,任何持有该链接的人都可永久访问此知识库,存在泄露风险。确定设为长期有效?',
'⚠️ 泄露风险提示',
{ type: 'warning', confirmButtonText: '仍要设为长期有效', cancelButtonText: '取消' },
)
} catch {
return // 用户取消
}
}
try {
await apiClient.post(`/knowledge-bases/${kbId}/set-expiry`, {
expires_in_minutes: val === 0 ? null : val,
})
ElMessage.success('链接有效期已更新。')
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
}
// 打开添加文本对话框(预填左侧选中的目录,对话框内的选择优先)
function openTextDialog() {
textForm.value.category_id = selectedCategoryId.value
showTextDialog.value = true
}
// 创建文本内容
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: 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 handleChangeDocCategory(doc: any, newCategoryId: string) {
try {
await apiClient.put(`/documents/${doc.id}`, { category_id: newCategoryId })
ElMessage.success('目录已更新。')
loadDocs()
loadCategories()
} catch { /* ignore */ }
}
async function handleDeleteDoc(doc: any) {
try {
await ElMessageBox.confirm(`确定删除「${doc.original_filename}」?`, '确认删除', { type: 'warning' })
await apiClient.delete(`/documents/${doc.id}`)
ElMessage.success('已删除。')
loadDocs()
loadCategories()
} catch { /* ignore */ }
}
async function handleReprocess(doc: any) {
try {
await apiClient.post(`/documents/${doc.id}/reprocess`)
ElMessage.success('重新解析已提交。')
loadDocs()
} catch { /* ignore */ }
}
async function handleCopyLink() {
const success = await copyToClipboard(aiUrl.value)
if (success) {
ElMessage.success('AI 链接已复制!')
} else {
ElMessage.error('复制失败,请手动长按复制。')
}
}
// 重新生成 AI 链接
async function handleRegenerateLink() {
try {
await ElMessageBox.confirm('重新生成链接后,旧链接将立即失效。确定继续?', '确认', { type: 'warning' })
const { data } = await apiClient.post(`/knowledge-bases/${kbId}/regenerate-token`)
aiUrl.value = `${window.location.origin}/k/${data.token}`
ElMessage.success('链接已重新生成!')
} 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()
loadDocs()
} catch { /* ignore */ }
}
function formatSize(bytes: number) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function statusType(status: string) {
if (status === 'READY') return 'success'
if (status === 'FAILED') return 'danger'
return 'warning'
}
// 获取分类名称
function getCategoryName(catId: string) {
const cat = allCategoriesFlat.value.find((c: any) => c.id === catId)
return cat ? cat.name : '未分类'
}
// AI 链接打码:显示域名,token 部分用 • 代替
const maskedAiUrl = computed(() => {
if (!aiUrl.value) return '尚未生成'
const idx = aiUrl.value.indexOf('/k/')
if (idx === -1) return '••••••••'
const prefix = aiUrl.value.slice(0, idx + 3)
const tokenLen = aiUrl.value.length - idx - 3
return prefix + '•'.repeat(tokenLen)
})
</script>
<template>
<div v-if="kb">
<!-- 手机端顶部操作栏 -->
<div class="mobile-header">
<h1 style="margin: 0; font-size: 20px">{{ kb.name }}</h1>
<div style="display: flex; gap: 8px; margin-top: 10px">
<el-button @click="handleCopyLink" size="small" style="flex: 1">📋 复制链接</el-button>
<el-button type="warning" @click="handleRegenerateLink" size="small" style="flex: 1">🔄 重置链接</el-button>
</div>
</div>
<div class="main-layout">
<!-- 左侧目录树桌面端常驻手机端抽屉 -->
<aside class="sidebar" :class="{ 'mobile-show': showMobileSidebar }">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
<h3 style="margin: 0; font-size: 16px">📁 目录</h3>
<el-button size="small" type="primary" @click="openAddCategory(null)">+ 新建</el-button>
</div>
<div
class="cat-item"
:class="{ active: !selectedCategoryId }"
@click="clearCategoryFilter"
>
📋 全部文档
</div>
<el-tree :data="categories" node-key="id" default-expand-all :expand-on-click-node="false">
<template #default="{ data }">
<div class="tree-node">
<span class="tree-label" :class="{ selected: selectedCategoryId === data.id }" @click="selectCategory(data)">
{{ data.is_folder ? '📁' : '📄' }} {{ data.name }}
<span v-if="data.doc_count > 0" style="color: #999; font-size: 12px">({{ data.doc_count }})</span>
</span>
<span class="tree-actions">
<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>
</aside>
<!-- 手机端遮罩 -->
<div v-if="showMobileSidebar" class="mobile-overlay" @click="showMobileSidebar = false"></div>
<!-- 右侧文档列表 -->
<main class="content">
<!-- 桌面端标题 -->
<div class="desktop-header">
<h1 style="margin: 0; font-size: 24px">{{ kb.name }}</h1>
<div style="display: flex; gap: 12px">
<el-button @click="handleCopyLink" size="large">📋 复制 AI 链接</el-button>
<el-button type="warning" @click="handleRegenerateLink" size="large">🔄 重新生成链接</el-button>
</div>
</div>
<!-- AI 链接打码显示只能复制获取 -->
<el-card style="margin-bottom: 16px" shadow="hover">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap">
<span style="font-weight: bold; font-size: 14px">AI 链接</span>
<span style="flex: 1; min-width: 200px; font-size: 14px; color: #666; font-family: monospace; user-select: none">
{{ maskedAiUrl }}
</span>
<el-select
:model-value="null"
placeholder="⏱️ 设置有效期"
style="width: 150px"
@change="(val: number) => handleExpiryChange(val)"
>
<el-option
v-for="opt in expiryOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button type="primary" @click="handleCopyLink" size="large">📋 复制 AI 链接</el-button>
</div>
<div style="display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: 8px; font-size: 12px">
<span :style="{ color: linkExpiry.is_expired ? '#f56c6c' : '#67c23a', fontWeight: linkExpiry.is_expired ? 'bold' : 'normal' }">
{{ expiryText }}
</span>
<span style="color: #999">🔒 链接即访问凭证已隐藏显示仅可通过复制按钮获取请勿公开传播</span>
</div>
</el-card>
<!-- 手机端目录按钮 + 操作按钮 -->
<div class="mobile-actions">
<el-button @click="showMobileSidebar = true" style="flex: 1">📁 目录</el-button>
<el-button @click="openTextDialog" style="flex: 1">✏️ 添加文本</el-button>
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf" style="flex: 1">
<el-button type="primary" :loading="uploading" style="width: 100%">📤 上传</el-button>
</el-upload>
</div>
<el-card shadow="hover">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px">
<span style="font-size: 16px; font-weight: bold">
📄 文档列表
<el-tag v-if="selectedCategoryPath" closable @close="clearCategoryFilter" style="margin-left: 8px">
{{ selectedCategoryPath }}
</el-tag>
</span>
<div class="desktop-actions">
<el-button @click="openTextDialog"> 添加文本</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>
</template>
<!-- 桌面端表格 -->
<div class="desktop-table">
<el-table :data="docs" v-loading="loading" style="width: 100%">
<el-table-column prop="original_filename" label="标题" min-width="180" show-overflow-tooltip />
<el-table-column label="目录" width="180">
<template #default="{ row }">
<el-select :model-value="row.category_id" @change="(val: string) => handleChangeDocCategory(row, val)" placeholder="选择目录" size="small" style="width: 100%">
<el-option v-for="cat in allCategoriesFlat" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
</template>
</el-table-column>
<el-table-column label="状态" width="90" 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 }">{{ formatSize(row.file_size) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" 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>
</div>
<!-- 手机端卡片列表 -->
<div class="mobile-cards">
<div v-for="doc in docs" :key="doc.id" class="doc-card">
<div style="font-weight: bold; font-size: 15px; margin-bottom: 8px">{{ doc.original_filename }}</div>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap">
<el-tag :type="statusType(doc.status)" size="small">{{ doc.status }}</el-tag>
<span style="color: #999; font-size: 12px">{{ formatSize(doc.file_size) }}</span>
</div>
<el-select :model-value="doc.category_id" @change="(val: string) => handleChangeDocCategory(doc, val)" placeholder="选择目录" size="small" style="width: 100%; margin-bottom: 8px">
<el-option v-for="cat in allCategoriesFlat" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
<div style="display: flex; gap: 8px">
<el-button size="small" style="flex: 1" @click="handleReprocess(doc)">重新解析</el-button>
<el-button size="small" type="danger" style="flex: 1" @click="handleDeleteDoc(doc)">删除</el-button>
</div>
</div>
</div>
</el-card>
</main>
</div>
<!-- 对话框 -->
<el-dialog v-model="showTextDialog" title="✏️ 添加文本内容" :width="isMobile ? '95%' : '700px'">
<el-form :model="textForm" label-position="top">
<el-form-item label="标题" required>
<el-input v-model="textForm.title" placeholder="文档标题" size="large" />
</el-form-item>
<el-form-item label="所属目录">
<el-select v-model="textForm.category_id" placeholder="选择目录" clearable style="width: 100%" size="large">
<el-option v-for="cat in allCategoriesFlat" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
</el-form-item>
<el-form-item label="格式">
<el-radio-group v-model="textForm.content_format" size="large">
<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" size="large" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showTextDialog = false" size="large">取消</el-button>
<el-button type="primary" :loading="textLoading" @click="handleCreateText" size="large">创建</el-button>
</template>
</el-dialog>
<el-dialog v-model="showCatDialog" :title="editingCatId ? '✏️ 编辑目录' : '📁 新建目录'" :width="isMobile ? '95%' : '450px'">
<el-form :model="catForm" label-position="top">
<el-form-item label="目录名称" required>
<el-input v-model="catForm.name" placeholder="如:公司基本信息" size="large" />
</el-form-item>
<el-form-item label="类型">
<el-radio-group v-model="catForm.is_folder" size="large">
<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" size="large">取消</el-button>
<el-button type="primary" :loading="catLoading" @click="handleSaveCategory" size="large">{{ editingCatId ? '保存' : '创建' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.main-layout {
display: flex;
gap: 20px;
}
.sidebar {
width: 280px;
flex-shrink: 0;
overflow-y: auto;
max-height: calc(100vh - 180px);
position: sticky;
top: 20px;
}
.content {
flex: 1;
min-width: 0;
}
.cat-item {
padding: 10px 12px;
cursor: pointer;
border-radius: 6px;
margin-bottom: 6px;
font-size: 14px;
background: #f5f7fa;
transition: all 0.2s;
}
.cat-item:hover {
background: #e8e8e8;
}
.cat-item.active {
background: #ecf5ff;
color: #409eff;
font-weight: bold;
}
.tree-node {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 4px 0;
}
.tree-label {
cursor: pointer;
font-size: 14px;
flex: 1;
}
.tree-label.selected {
color: #409eff;
font-weight: bold;
}
.tree-actions {
display: flex;
gap: 2px;
}
.mobile-header {
display: none;
}
.mobile-actions {
display: none;
}
.mobile-cards {
display: none;
}
.desktop-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.desktop-actions {
display: flex;
gap: 10px;
}
.mobile-overlay {
display: none;
}
/* 手机端适配 */
@media (max-width: 768px) {
.main-layout {
flex-direction: column;
}
.sidebar {
display: none;
position: fixed;
top: 0;
left: 0;
width: 80%;
max-width: 300px;
height: 100vh;
background: #fff;
z-index: 1000;
padding: 20px;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
overflow-y: auto;
max-height: 100vh;
}
.sidebar.mobile-show {
display: block;
}
.mobile-overlay {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.content {
width: 100%;
}
.mobile-header {
display: block;
margin-bottom: 16px;
}
.desktop-header {
display: none;
}
.mobile-actions {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.desktop-actions {
display: none;
}
.desktop-table {
display: none;
}
.mobile-cards {
display: block;
}
.doc-card {
border: 1px solid #eee;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
}
</style>