You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
692 lines
29 KiB
692 lines
29 KiB
<template>
|
|
<t-card class="doc-list-card" title="文档列表" :bordered="false">
|
|
<div class="doc-layout">
|
|
<!-- 左侧:目录树面板 -->
|
|
<aside class="doc-tree-panel" :style="{ width: treeWidth + 'px' }">
|
|
<div class="tree-panel-header">
|
|
<span class="tree-panel-title">📁 文档目录</span>
|
|
<t-button variant="text" size="small" @click="loadTreeData">刷新</t-button>
|
|
</div>
|
|
<div class="tree-panel-body">
|
|
<t-loading v-if="folderTreeLoading" text="加载中..." size="small" />
|
|
<t-tree v-else
|
|
:data="treeData"
|
|
:keys="{ value: 'value', label: 'label', children: 'children' }"
|
|
:activable="true"
|
|
:active-multiple="false"
|
|
hover
|
|
empty="暂无分类与目录"
|
|
v-model:actived="activedKeys"
|
|
@active="onTreeActive"
|
|
>
|
|
<template #label="{ node }">
|
|
<span class="tree-node">
|
|
<span class="tree-label-text" :title="node.data.label">{{ node.data.label }}</span>
|
|
<t-dropdown class="tree-ops" trigger="click" :options="nodeActionOptions(node)" @click="(item: any) => onNodeAction(item, node)">
|
|
<t-button size="small" variant="text" shape="square" @click.stop>
|
|
<template #icon><MoreIcon /></template>
|
|
</t-button>
|
|
</t-dropdown>
|
|
</span>
|
|
</template>
|
|
</t-tree>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- 可拖拽分隔条 -->
|
|
<div class="tree-resizer" :class="{ dragging: isResizing }" @mousedown="startResize"></div>
|
|
|
|
<!-- 右侧:工具栏 + 表格 -->
|
|
<div class="doc-content">
|
|
<!-- 工具栏:搜索 + 筛选 + 批量操作 -->
|
|
<div class="toolbar">
|
|
<t-input v-model="keyword" placeholder="搜索文档标题或文件名..." clearable @change="debouncedLoad" style="max-width:260px;" size="small" />
|
|
<t-select v-model="filterCategory" :options="categoryOptions" placeholder="全部分类" clearable size="small" style="width:140px;" @change="onCategoryChange" />
|
|
<t-select v-model="filterStatus" :options="statusOptions" placeholder="全部状态" clearable size="small" style="width:110px;" @change="load()" />
|
|
<t-button variant="outline" size="small" @click="load()">刷新</t-button>
|
|
<t-tag v-if="hasProcessing" theme="warning" size="small">⏳ 有文档处理中...</t-tag>
|
|
<t-tag v-if="filterFolder" theme="primary" variant="light" size="small" :closable="true" @close="clearFolderFilter">📁 目录筛选</t-tag>
|
|
|
|
<template v-if="selectedIds.size > 0">
|
|
<t-divider layout="vertical" />
|
|
<span class="selected-count">已选 {{ selectedIds.size }} 项</span>
|
|
<t-button theme="danger" size="small" @click="batchRemove">批量删除</t-button>
|
|
<t-button variant="outline" size="small" @click="batchReprocess">批量重新处理</t-button>
|
|
<t-button variant="outline" size="small" @click="batchEnable">批量启用</t-button>
|
|
<t-button variant="outline" size="small" @click="batchDisable">批量禁用</t-button>
|
|
<t-button variant="outline" size="small" @click="openMove(Array.from(selectedIds))">移动</t-button>
|
|
<t-button variant="text" size="small" @click="clearSelection">取消选择</t-button>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- 表格:外层容器撑满剩余高度,表格在其中内部滚动(表头固定) -->
|
|
<div class="doc-table-wrap">
|
|
<BaseTable
|
|
:max-height="'calc(100vh - 220px)'"
|
|
:data="documents" :columns="columns" row-key="id" :loading="loading"
|
|
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }"
|
|
:selected-row-keys="Array.from(selectedIds)"
|
|
:sort="sortInfo"
|
|
@page-change="onPageChange"
|
|
@select-change="onSelectChange"
|
|
@sort-change="onSortChange"
|
|
>
|
|
<template #title="{ row }">
|
|
<div><strong>{{ row.title }}</strong></div>
|
|
<div style="font-size:11px;color:var(--td-text-color-placeholder);">{{ row.sourceName || '' }}</div>
|
|
<div v-if="getDocTags(row).length" style="margin-top:2px;">
|
|
<t-tag v-for="tag in getDocTags(row)" :key="tag" size="small" variant="light" theme="primary" style="margin-right:2px;">{{ tag }}</t-tag>
|
|
</div>
|
|
</template>
|
|
<template #fileType="{ row }"><t-tag size="small" variant="light">{{ row.fileType }}</t-tag></template>
|
|
<template #fileSize="{ row }"><span style="white-space:nowrap;">{{ formatSize(row.fileSize) }}</span></template>
|
|
<template #categoryId="{ row }">
|
|
<span class="doc-pos" :title="docPath(row)">{{ docPath(row) }}</span>
|
|
</template>
|
|
<template #status="{ row }">
|
|
<t-tag size="small" :theme="row.status === 'READY' ? 'success' : row.status === 'PROCESSING' ? 'warning' : 'danger'"
|
|
variant="light">{{ statusLabel(row.status) }}</t-tag>
|
|
</template>
|
|
<template #enabled="{ row }">
|
|
<t-switch v-model="row._enabled" size="small" @change="toggle(row)" />
|
|
</template>
|
|
<template #op="{ row }">
|
|
<t-space :size="2">
|
|
<t-button size="small" variant="text" @click="viewDetail(row.id)">查看</t-button>
|
|
<t-button v-show="row.filePath" size="small" variant="text" @click="download(row.id)">下载</t-button>
|
|
<t-button size="small" variant="text" @click="openMove([row.id])">移动</t-button>
|
|
<t-button size="small" variant="text" @click="reprocess(row.id)">重新处理</t-button>
|
|
<t-button size="small" variant="text" theme="danger" @click="remove(row.id)">删除</t-button>
|
|
</t-space>
|
|
</template>
|
|
</BaseTable>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 目录创建 / 重命名弹窗 -->
|
|
<t-dialog v-model:visible="folderDialog.visible" :header="folderDialog.title" width="420px" :footer="false">
|
|
<t-form label-align="top">
|
|
<t-form-item label="目录名称">
|
|
<t-input v-model="folderDialog.name" placeholder="请输入目录名称" @enter="submitFolderDialog" />
|
|
</t-form-item>
|
|
</t-form>
|
|
<div class="dialog-footer">
|
|
<t-button variant="outline" @click="folderDialog.visible = false">取消</t-button>
|
|
<t-button theme="primary" @click="submitFolderDialog">保存</t-button>
|
|
</div>
|
|
</t-dialog>
|
|
|
|
<!-- 移动文档弹窗:选择目标位置(分类根或目录) -->
|
|
<t-dialog v-model:visible="moveDialog.visible" header="移动文档" width="440px" :footer="false">
|
|
<div class="move-tree-tip">选择目标位置(分类根或目录):</div>
|
|
<t-tree
|
|
v-if="moveDialog.visible"
|
|
:data="moveTreeData"
|
|
:keys="{ value: 'value', label: 'label', children: 'children' }"
|
|
:activable="true"
|
|
:active-multiple="false"
|
|
hover
|
|
expand-all
|
|
v-model:actived="moveActiveKeys"
|
|
@active="onMoveTreeChange"
|
|
style="max-height:360px;overflow:auto;"
|
|
/>
|
|
<div class="dialog-footer">
|
|
<t-button variant="outline" @click="moveDialog.visible = false">取消</t-button>
|
|
<t-button theme="primary" :disabled="!moveDialog.target" @click="confirmMove">移动</t-button>
|
|
</div>
|
|
</t-dialog>
|
|
</t-card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import { useDocumentStore } from '@/stores/document'
|
|
import { useCategoryStore } from '@/stores/category'
|
|
import { useDialogStore } from '@/stores/dialog'
|
|
import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments, toggleDocument, batchToggleDocuments, batchMoveDocuments, downloadDocument } from '@/api/document'
|
|
import { getFolderList, createFolder, updateFolder, deleteFolder } from '@/api/folder'
|
|
import { getCategoryTree } from '@/api/category'
|
|
import { toast } from '@/utils/toast'
|
|
import { formatBytes, formatDate } from '@/utils/format'
|
|
import { useConfirm } from '@/composables/useConfirm'
|
|
import { useDebounce } from '@/composables/useDebounce'
|
|
import { MoreIcon } from 'tdesign-icons-vue-next'
|
|
|
|
const { confirm } = useConfirm()
|
|
const { debounce } = useDebounce()
|
|
|
|
const documentStore = useDocumentStore()
|
|
const categoryStore = useCategoryStore()
|
|
const dialogStore = useDialogStore()
|
|
|
|
// ==================== 状态 ====================
|
|
const documents = ref<any[]>([])
|
|
const page = ref(1)
|
|
const pageSize = ref(10)
|
|
const total = ref(0)
|
|
const loading = ref(false)
|
|
const filterCategory = ref('')
|
|
const filterStatus = ref('')
|
|
const keyword = ref('')
|
|
const selectedIds = ref(new Set<string>())
|
|
const hasProcessing = ref(false)
|
|
|
|
// 移动文档弹窗状态
|
|
const moveDialog = ref({ visible: false, ids: [] as string[], target: null as any })
|
|
const moveActiveKeys = ref<string[]>([])
|
|
|
|
// 目录树相关状态
|
|
const treeData = ref<any[]>([])
|
|
const activedKeys = ref<string[]>([])
|
|
const filterFolder = ref('')
|
|
const folderTreeLoading = ref(false)
|
|
// 目录树宽度(可拖动,不持久化)
|
|
const treeWidth = ref(240)
|
|
const isResizing = ref(false)
|
|
const MIN_TREE_WIDTH = 200
|
|
const MAX_TREE_WIDTH = 480
|
|
let resizeCleanup: (() => void) | null = null
|
|
// 目录 id → { name, parentId },位置列据此沿 parentId 回溯拼目录路径
|
|
const folderMap = ref<Record<string, { name: string; parentId: string | number }>>({})
|
|
// 表格排序状态(服务端排序)
|
|
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
|
|
|
|
// 目录创建/重命名弹窗
|
|
const folderDialog = ref({
|
|
visible: false,
|
|
title: '',
|
|
name: '',
|
|
mode: 'create' as 'create' | 'rename',
|
|
categoryId: '',
|
|
parentId: '',
|
|
folderId: '',
|
|
})
|
|
|
|
// ==================== 轮询 ====================
|
|
let pollTimer: any = null
|
|
|
|
function startPolling() {
|
|
stopPolling()
|
|
hasProcessing.value = true
|
|
pollTimer = setInterval(async () => {
|
|
await loadData(page.value)
|
|
if (!documents.value.some(d => d.status === 'PROCESSING' || d.status === 'processing')) {
|
|
stopPolling()
|
|
documentStore.loadStats()
|
|
toast('所有文档处理完成', 'success')
|
|
}
|
|
}, 2000)
|
|
}
|
|
|
|
function stopPolling() {
|
|
hasProcessing.value = false
|
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
|
}
|
|
|
|
onUnmounted(() => { stopPolling(); if (resizeCleanup) resizeCleanup() })
|
|
|
|
// ==================== 表格列 ====================
|
|
const columns = [
|
|
{ colKey: 'row-select', type: 'multiple', width: 50 },
|
|
{ colKey: 'title', title: '标题', width: 220, sorter: true },
|
|
{ colKey: 'fileType', title: '类型', width: 70, sorter: true },
|
|
{ colKey: 'fileSize', title: '大小', width: 80, sorter: true },
|
|
{ colKey: 'categoryId', title: '位置', width: 200 },
|
|
{ colKey: 'status', title: '状态', width: 80 },
|
|
{ colKey: 'enabled', title: '启用', width: 60 },
|
|
{ colKey: 'chunkCount', title: '分块', width: 60, sorter: true },
|
|
{ colKey: 'createTime', title: '创建时间', width: 160, sorter: true, cell: (_:any,{row}:any) => formatDate(row.createTime) },
|
|
{ colKey: 'op', title: '操作', width: 240 },
|
|
]
|
|
|
|
// ==================== 选项 ====================
|
|
const categoryOptions = computed(() => [{ label: '全部分类', value: '' }, ...categoryStore.categories.map(c => ({ label: c.name, value: String(c.id) }))])
|
|
const statusOptions = [{ label: '全部状态', value: '' }, { label: '已完成', value: 'READY' }, { label: '处理中', value: 'PROCESSING' }, { label: '失败', value: 'FAILED' }]
|
|
// 移动弹窗目录树:确保有「未分类」节点,保留移出到未分类的能力
|
|
const moveTreeData = computed(() => {
|
|
const hasUnclassified = treeData.value.some(n => n.value === 'cat:0')
|
|
if (hasUnclassified) return treeData.value
|
|
return [{ value: 'cat:0', label: '未分类', nodeType: 'category', categoryId: '0', children: [] }, ...treeData.value]
|
|
})
|
|
|
|
// ==================== 工具函数 ====================
|
|
function getDocTags(doc: any): string[] {
|
|
if (!doc.tags) return []
|
|
if (doc.tags.tags && Array.isArray(doc.tags.tags)) return doc.tags.tags
|
|
if (Array.isArray(doc.tags)) return doc.tags
|
|
return []
|
|
}
|
|
|
|
function statusLabel(s: string) { return s === 'READY' ? '已完成' : s === 'PROCESSING' ? '处理中' : s === 'FAILED' ? '失败' : s }
|
|
function formatSize(b: number) { return b ? formatBytes(b) : '-' }
|
|
|
|
/** 计算文档所在目录路径(不含分类名);直接挂分类根/无目录时返回「—」 */
|
|
function docPath(row: any): string {
|
|
let fid = row.folderId != null ? String(row.folderId) : '0'
|
|
const chain: string[] = []
|
|
let guard = 0
|
|
while (fid && fid !== '0' && guard++ < 20) {
|
|
const f = folderMap.value[fid]
|
|
if (!f) break
|
|
chain.unshift(f.name)
|
|
fid = String(f.parentId ?? '0')
|
|
}
|
|
return chain.length ? chain.join(' / ') : '—'
|
|
}
|
|
|
|
// ==================== 搜索防抖 ====================
|
|
const debouncedLoad = debounce(() => load(1))
|
|
|
|
// ==================== 目录树:组装数据 ====================
|
|
/** 把分类树 + 扁平目录数组组装成 t-tree 所需的数据结构 */
|
|
function buildTree(cats: any[], folders: any[]): any[] {
|
|
// 目录按 categoryId 分组
|
|
const byCat = new Map<string, any[]>()
|
|
for (const f of folders || []) {
|
|
const cid = String(f.categoryId ?? '')
|
|
if (!byCat.has(cid)) byCat.set(cid, [])
|
|
byCat.get(cid)!.push(f)
|
|
}
|
|
|
|
// 记录已挂到分类节点的分类 id,用于兜底处理孤儿目录
|
|
const consumed = new Set<string>()
|
|
|
|
// 扁平目录数组按 parentId 递归组装成树
|
|
function buildFolderTree(list: any[]): any[] {
|
|
const map = new Map<string, any>()
|
|
list.forEach(f => map.set(String(f.id), { ...f, children: [] }))
|
|
const roots: any[] = []
|
|
map.forEach(node => {
|
|
const pid = String(node.parentId ?? '')
|
|
if (pid && pid !== '0' && map.has(pid)) map.get(pid)!.children.push(node)
|
|
else roots.push(node)
|
|
})
|
|
return roots
|
|
}
|
|
|
|
// 目录节点 → t-tree 节点
|
|
function folderNode(f: any): any {
|
|
return {
|
|
value: 'folder:' + f.id,
|
|
label: f.name,
|
|
nodeType: 'folder',
|
|
categoryId: f.categoryId,
|
|
folderId: f.id,
|
|
children: (f.children || []).map(folderNode),
|
|
}
|
|
}
|
|
|
|
// 分类节点 → t-tree 节点(children = 该分类下目录 + 子分类)
|
|
function categoryNode(c: any): any {
|
|
const cid = String(c.id)
|
|
consumed.add(cid)
|
|
const folderRoots = buildFolderTree(byCat.get(cid) || []).map(folderNode)
|
|
const subCats = (c.children || []).map(categoryNode)
|
|
return {
|
|
value: 'cat:' + cid,
|
|
label: c.name,
|
|
nodeType: 'category',
|
|
categoryId: c.id,
|
|
children: [...folderRoots, ...subCats],
|
|
}
|
|
}
|
|
|
|
const roots = (cats || []).map(categoryNode)
|
|
|
|
// 兜底:未匹配到任何分类的目录挂到「未分类」节点,避免数据丢失
|
|
const orphanIds: string[] = []
|
|
for (const cid of byCat.keys()) {
|
|
if (!consumed.has(cid)) orphanIds.push(cid)
|
|
}
|
|
if (orphanIds.length) {
|
|
const orphanFolders = orphanIds.flatMap(cid => buildFolderTree(byCat.get(cid) || []).map(folderNode))
|
|
roots.push({ value: 'cat:0', label: '未分类', nodeType: 'category', categoryId: '0', children: orphanFolders })
|
|
}
|
|
|
|
return roots
|
|
}
|
|
|
|
/** 拉取分类树与目录列表并组装目录树 */
|
|
async function loadTreeData() {
|
|
folderTreeLoading.value = true
|
|
try {
|
|
const [catRes, folderRes] = await Promise.all([getCategoryTree(), getFolderList()])
|
|
const cats = catRes.success ? (catRes.data || []) : []
|
|
const folders = folderRes.success ? (folderRes.data || []) : []
|
|
treeData.value = buildTree(cats, folders)
|
|
// 目录映射:位置列只回溯目录链,无需分类链映射
|
|
folderMap.value = {}
|
|
for (const f of folders || []) folderMap.value[String(f.id)] = { name: f.name, parentId: f.parentId }
|
|
} catch (e: any) {
|
|
toast('加载目录树失败:' + e.message, 'error')
|
|
} finally {
|
|
folderTreeLoading.value = false
|
|
}
|
|
}
|
|
|
|
/** 目录树节点激活:按类型联动分类/目录筛选 */
|
|
function onTreeActive(value: any[], ctx: any) {
|
|
// 程序化清空高亮(trigger=setItem)或空选中时不触发过滤
|
|
if (!value || value.length === 0) return
|
|
if (ctx?.trigger === 'setItem') return
|
|
const data = ctx?.node?.data
|
|
if (!data) return
|
|
if (data.nodeType === 'category') {
|
|
filterCategory.value = data.categoryId != null ? String(data.categoryId) : ''
|
|
filterFolder.value = ''
|
|
} else if (data.nodeType === 'folder') {
|
|
filterFolder.value = String(data.folderId)
|
|
filterCategory.value = data.categoryId != null ? String(data.categoryId) : ''
|
|
}
|
|
load(1)
|
|
}
|
|
|
|
/** 分类下拉变化时:清空目录筛选与树高亮 */
|
|
function onCategoryChange() {
|
|
filterFolder.value = ''
|
|
activedKeys.value = []
|
|
load(1)
|
|
}
|
|
|
|
/** 清除目录筛选(点击工具栏标签关闭) */
|
|
function clearFolderFilter() {
|
|
filterFolder.value = ''
|
|
activedKeys.value = []
|
|
load(1)
|
|
}
|
|
|
|
/** 目录树宽度拖拽 */
|
|
function startResize(e: MouseEvent) {
|
|
e.preventDefault()
|
|
const startX = e.clientX
|
|
const startWidth = treeWidth.value
|
|
isResizing.value = true
|
|
const onMove = (ev: MouseEvent) => {
|
|
treeWidth.value = Math.min(MAX_TREE_WIDTH, Math.max(MIN_TREE_WIDTH, startWidth + (ev.clientX - startX)))
|
|
}
|
|
const onUp = () => {
|
|
document.removeEventListener('mousemove', onMove)
|
|
document.removeEventListener('mouseup', onUp)
|
|
document.body.style.userSelect = ''
|
|
document.body.style.cursor = ''
|
|
isResizing.value = false
|
|
resizeCleanup = null
|
|
}
|
|
document.addEventListener('mousemove', onMove)
|
|
document.addEventListener('mouseup', onUp)
|
|
document.body.style.userSelect = 'none'
|
|
document.body.style.cursor = 'col-resize'
|
|
resizeCleanup = onUp
|
|
}
|
|
|
|
/** 表格排序变化:更新排序状态并重新加载(服务端排序) */
|
|
function onSortChange(sort: any) {
|
|
sortInfo.value = sort && sort.sortBy ? { sortBy: sort.sortBy, descending: !!sort.descending } : null
|
|
load(1)
|
|
}
|
|
|
|
// ==================== 目录操作 ====================
|
|
/** 目录/分类节点操作菜单项 */
|
|
function nodeActionOptions(node: any): any[] {
|
|
if (node.data.nodeType === 'folder') {
|
|
return [
|
|
{ content: '新建子目录', value: 'create' },
|
|
{ content: '重命名', value: 'rename' },
|
|
{ content: '删除', value: 'remove', theme: 'error', divider: true },
|
|
]
|
|
}
|
|
return [{ content: '新建目录', value: 'create' }]
|
|
}
|
|
|
|
/** 目录/分类节点操作分发 */
|
|
function onNodeAction(item: any, node: any) {
|
|
const d = node.data
|
|
if (item.value === 'create') {
|
|
openCreateFolder(d.categoryId, d.nodeType === 'folder' ? d.folderId : undefined)
|
|
} else if (item.value === 'rename') {
|
|
openRenameFolder(d)
|
|
} else if (item.value === 'remove') {
|
|
removeFolder(d)
|
|
}
|
|
}
|
|
|
|
function openCreateFolder(categoryId: any, parentId?: any) {
|
|
folderDialog.value = {
|
|
visible: true,
|
|
title: parentId != null ? '新建子目录' : '新建目录',
|
|
name: '',
|
|
mode: 'create',
|
|
categoryId: String(categoryId),
|
|
parentId: parentId != null ? String(parentId) : '',
|
|
folderId: '',
|
|
}
|
|
}
|
|
|
|
function openRenameFolder(node: any) {
|
|
folderDialog.value = {
|
|
visible: true,
|
|
title: '重命名目录',
|
|
name: node.label || '',
|
|
mode: 'rename',
|
|
categoryId: '',
|
|
parentId: '',
|
|
folderId: String(node.folderId),
|
|
}
|
|
}
|
|
|
|
async function submitFolderDialog() {
|
|
const name = folderDialog.value.name.trim()
|
|
if (!name) { toast('请输入目录名称', 'error'); return }
|
|
try {
|
|
if (folderDialog.value.mode === 'create') {
|
|
const payload: any = { name, categoryId: folderDialog.value.categoryId }
|
|
if (folderDialog.value.parentId) payload.parentId = folderDialog.value.parentId
|
|
const r = await createFolder(payload)
|
|
if (r.success) { toast('目录创建成功', 'success'); folderDialog.value.visible = false; await loadTreeData() }
|
|
else toast(r.message || '创建失败', 'error')
|
|
} else {
|
|
const r = await updateFolder(folderDialog.value.folderId, { name })
|
|
if (r.success) { toast('目录重命名成功', 'success'); folderDialog.value.visible = false; await loadTreeData() }
|
|
else toast(r.message || '重命名失败', 'error')
|
|
}
|
|
} catch (e: any) { toast('操作失败:' + e.message, 'error') }
|
|
}
|
|
|
|
async function removeFolder(node: any) {
|
|
if (!await confirm(`确定删除目录「${node.label}」?目录内文档将被移到分类根`)) return
|
|
try {
|
|
const r = await deleteFolder(node.folderId)
|
|
if (r.success) {
|
|
const moved = r.data?.movedDocuments
|
|
toast(moved != null ? `目录已删除,${moved} 个文档已移至分类根` : (r.message || '目录已删除'), 'success')
|
|
// 若删除的是当前筛选目录,清空目录筛选
|
|
if (filterFolder.value === String(node.folderId)) { filterFolder.value = ''; activedKeys.value = [] }
|
|
await loadTreeData()
|
|
loadData()
|
|
documentStore.loadStats()
|
|
} else toast(r.message || '删除失败', 'error')
|
|
} catch (e: any) { toast('删除失败:' + e.message, 'error') }
|
|
}
|
|
|
|
// ==================== 数据加载 ====================
|
|
async function loadData(p = page.value) {
|
|
page.value = p
|
|
loading.value = true
|
|
try {
|
|
const json = await listDocuments(p, pageSize.value, filterCategory.value || undefined, filterStatus.value || undefined, keyword.value.trim() || undefined, filterFolder.value || undefined, sortInfo.value?.sortBy || undefined, sortInfo.value ? (sortInfo.value.descending ? 'desc' : 'asc') : undefined)
|
|
if (json.success) {
|
|
documents.value = (json.data || []).map((d: any) => ({ ...d, _enabled: d.enabled !== false }))
|
|
total.value = json.total || 0
|
|
if (documents.value.some((d: any) => d.status === 'PROCESSING' || d.status === 'processing') && !pollTimer) startPolling()
|
|
} else toast(json.message || '查询失败', 'error')
|
|
} catch (e: any) { toast('加载文档失败:' + e.message, 'error') }
|
|
finally { loading.value = false }
|
|
}
|
|
|
|
function load(p = 1) { selectedIds.value = new Set(); loadData(p) }
|
|
function onPageChange(info: { current: number; pageSize: number }) {
|
|
pageSize.value = info.pageSize
|
|
load(info.current)
|
|
}
|
|
function onSelectChange(keys: (string | number)[]) { selectedIds.value = new Set(keys.map(String)) }
|
|
|
|
// ==================== 操作 ====================
|
|
function clearSelection() { selectedIds.value = new Set() }
|
|
function viewDetail(id: string) { dialogStore.openDetail(id) }
|
|
function download(id: string) { downloadDocument(id) }
|
|
|
|
async function toggle(doc: any) {
|
|
try {
|
|
const json = await toggleDocument(doc.id)
|
|
if (json.success) { doc.enabled = json.data.enabled; doc._enabled = json.data.enabled; toast(json.message, 'success') }
|
|
else { toast(json.message || '操作失败', 'error'); loadData() }
|
|
} catch (e: any) { toast('操作失败:' + e.message, 'error'); loadData() }
|
|
}
|
|
|
|
async function remove(id: string) {
|
|
if (!await confirm('确定删除此文档?关联的向量也将被删除')) return
|
|
try {
|
|
const json = await deleteDocument(id)
|
|
if (json.success) { toast(`已删除,连带删除 ${json.deletedVectors || 0} 个向量`, 'success'); loadData(); documentStore.loadStats() }
|
|
else toast(json.message || '删除失败', 'error')
|
|
} catch (e: any) { toast('删除失败:' + e.message, 'error') }
|
|
}
|
|
|
|
async function reprocess(id: string) {
|
|
if (!await confirm('确定重新处理此文档?')) return
|
|
try {
|
|
const json = await reprocessDocument(id)
|
|
if (json.success) { toast(json.message || '已提交重新处理', 'success'); loadData(); if (!pollTimer) startPolling() }
|
|
else toast(json.message || '重新处理失败', 'error')
|
|
} catch (e: any) { toast('重新处理失败:' + e.message, 'error') }
|
|
}
|
|
|
|
async function batchRemove() {
|
|
const ids = Array.from(selectedIds.value)
|
|
if (!await confirm(`确定删除选中的 ${ids.length} 个文档?`)) return
|
|
try {
|
|
const json = await batchDeleteDocuments(ids)
|
|
if (json.success) { toast(json.message, 'success'); selectedIds.value = new Set(); loadData(); documentStore.loadStats() }
|
|
else toast(json.message || '批量删除失败', 'error')
|
|
} catch (e: any) { toast('批量删除失败:' + e.message, 'error') }
|
|
}
|
|
|
|
async function batchReprocess() {
|
|
const ids = Array.from(selectedIds.value)
|
|
if (!await confirm(`确定重新处理选中的 ${ids.length} 个文档?`)) return
|
|
try {
|
|
const json = await batchReprocessDocuments(ids)
|
|
if (json.success) { toast(json.message, 'success'); selectedIds.value = new Set(); loadData(); if (!pollTimer) startPolling() }
|
|
else toast(json.message || '批量重新处理失败', 'error')
|
|
} catch (e: any) { toast('批量重新处理失败:' + e.message, 'error') }
|
|
}
|
|
|
|
async function batchEnable() {
|
|
const ids = Array.from(selectedIds.value)
|
|
if (!await confirm(`确定启用选中的 ${ids.length} 个文档?`)) return
|
|
try {
|
|
const json = await batchToggleDocuments(ids, true)
|
|
if (json.success) { toast(json.message, 'success'); selectedIds.value = new Set(); loadData() }
|
|
else toast(json.message || '批量启用失败', 'error')
|
|
} catch (e: any) { toast('批量启用失败:' + e.message, 'error') }
|
|
}
|
|
|
|
async function batchDisable() {
|
|
const ids = Array.from(selectedIds.value)
|
|
if (!await confirm(`确定禁用选中的 ${ids.length} 个文档?禁用后不参与 RAG 检索`)) return
|
|
try {
|
|
const json = await batchToggleDocuments(ids, false)
|
|
if (json.success) { toast(json.message, 'success'); selectedIds.value = new Set(); loadData() }
|
|
else toast(json.message || '批量禁用失败', 'error')
|
|
} catch (e: any) { toast('批量禁用失败:' + e.message, 'error') }
|
|
}
|
|
|
|
function openMove(ids: string[]) {
|
|
moveDialog.value = { visible: true, ids, target: null }
|
|
moveActiveKeys.value = []
|
|
}
|
|
|
|
function onMoveTreeChange(_value: any[], ctx: any) {
|
|
moveDialog.value.target = ctx?.node?.data ?? null
|
|
}
|
|
|
|
async function confirmMove() {
|
|
const t = moveDialog.value.target
|
|
if (!t) { toast('请选择目标位置', 'error'); return }
|
|
// 目标为目录 → folderId=目录ID、categoryId=目录所属分类;目标为分类根 → folderId=0、categoryId=分类
|
|
const folderId = t.nodeType === 'folder' ? String(t.folderId ?? '0') : '0'
|
|
const categoryId = String(t.categoryId ?? '0')
|
|
const ids = moveDialog.value.ids
|
|
try {
|
|
const json = await batchMoveDocuments(ids, categoryId, folderId)
|
|
if (json.success) {
|
|
toast(json.message, 'success')
|
|
moveDialog.value.visible = false
|
|
moveActiveKeys.value = []
|
|
selectedIds.value = new Set()
|
|
loadData()
|
|
documentStore.loadStats()
|
|
} else {
|
|
toast(json.message || '移动失败', 'error')
|
|
}
|
|
} catch (e: any) {
|
|
toast('移动失败:' + e.message, 'error')
|
|
}
|
|
}
|
|
|
|
// 初始化
|
|
onMounted(async () => { await loadTreeData() })
|
|
loadData()
|
|
</script>
|
|
|
|
<style scoped>
|
|
.selected-count { font-size: 12px; font-weight: 600; color: var(--td-text-color-primary); }
|
|
/* 位置列:长路径省略号截断,悬停 title 看全 */
|
|
.doc-pos { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
|
|
|
|
/* 左右分栏布局 */
|
|
/* 卡片锁定一屏高度:左侧目录树独立滚动,右侧工具栏固定 + 表格内部滚动 */
|
|
.doc-list-card { height: 100%; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
|
|
.doc-list-card :deep(.t-card__header) { flex-shrink: 0; }
|
|
/* 关键修复:TDesign 在 header 与 body 之间有一层 .t-loading__parent,须让它参与 flex 高度链,
|
|
否则 .t-card__body 的 flex:1 不生效,目录树无法在面板内滚动而撑高整页 */
|
|
.doc-list-card :deep(.t-loading__parent) { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
.doc-list-card :deep(.t-card__body) { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
|
|
|
|
.doc-layout { flex: 1; min-height: 0; display: flex; align-items: stretch; }
|
|
.doc-tree-panel {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
padding-right: 12px;
|
|
border-right: 1px solid var(--color-border);
|
|
}
|
|
.tree-resizer {
|
|
flex-shrink: 0;
|
|
width: 6px;
|
|
margin-left: -6px;
|
|
cursor: col-resize;
|
|
background: transparent;
|
|
transition: background .2s;
|
|
}
|
|
.tree-resizer:hover, .tree-resizer.dragging { background: var(--color-primary); }
|
|
.tree-panel-body { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; }
|
|
.doc-content { flex: 1; min-width: 0; overflow: hidden; padding-left: 16px; display: flex; flex-direction: column; }
|
|
/* 表格宿主:撑满工具栏下方剩余高度,表格 max-height:100% 相对它解析,实现内部滚动与固定表头 */
|
|
.doc-table-wrap { flex: 1; min-height: 0; overflow: hidden; }
|
|
|
|
.tree-panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
|
.tree-panel-title { font-size: 13px; font-weight: 600; }
|
|
|
|
/* 树节点 hover 操作按钮 */
|
|
.tree-node { display: flex; align-items: center; width: 100%; min-width: 0; }
|
|
.tree-label-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.tree-ops { display: inline-flex; align-items: center; flex-shrink: 0; margin-left: 4px; visibility: hidden; }
|
|
.tree-node:hover .tree-ops { visibility: visible; }
|
|
|
|
.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
|
.move-tree-tip { font-size: 12px; color: var(--td-text-color-placeholder); margin-bottom: 8px; }
|
|
</style>
|