本地 RAG 知识库
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

<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>