Browse Source

feat(document): 目录树独立滚动与文件夹上传完善

- 文档列表目录树独立滚动
- 上传页目录选择器改为先选分类再选目录
- 文件夹上传标题剥离目录前缀(原样文件名)
- 部分文件失败时弹窗按原因分组展示失败明细
Spring-AI-1.1.2
wanghanlin 2 weeks ago
parent
commit
1ffd960377
  1. 16
      frontend/src/views/DocList.vue
  2. 133
      frontend/src/views/DocUpload.vue
  3. 22
      src/main/java/com/wok/supportbot/service/DocumentService.java

16
frontend/src/views/DocList.vue

@ -7,6 +7,7 @@
<span class="tree-panel-title">📁 文档目录</span>
<t-button variant="text" size="small" @click="loadTreeData">刷新</t-button>
</div>
<div class="tree-panel-body">
<div v-if="folderTreeLoading" class="tree-loading">加载中...</div>
<t-tree v-else
:data="treeData"
@ -16,6 +17,7 @@
hover
expand-all
empty="暂无分类与目录"
:height="'100%'"
v-model:actived="activedKeys"
@active="onTreeActive"
>
@ -35,6 +37,7 @@
</span>
</template>
</t-tree>
</div>
</aside>
<!-- 右侧工具栏 + 表格 -->
@ -522,18 +525,21 @@ loadData()
.selected-count { font-size: 12px; font-weight: 600; color: var(--td-text-color-primary); }
/* 左右分栏布局 */
/* 卡片撑满内容区,实现左右独立滚动 */
.doc-list-card { height: 100%; display: flex; flex-direction: column; }
.doc-list-card :deep(.t-card__body) { flex: 1; min-height: 0; overflow: hidden; }
/* 卡片锁定一屏高度,左右各自独立滚动 */
.doc-list-card { height: calc(100vh - 48px); display: flex; flex-direction: column; }
.doc-list-card :deep(.t-card__body) { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
.doc-layout { display: flex; gap: 16px; align-items: stretch; height: 100%; }
.doc-layout { flex: 1; min-height: 0; display: flex; gap: 16px; align-items: stretch; }
.doc-tree-panel {
width: 240px;
flex-shrink: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
overflow: hidden;
padding-right: 12px;
border-right: 1px solid var(--color-border);
}
.tree-panel-body { flex: 1; min-height: 0; }
.doc-content { flex: 1; min-width: 0; overflow-y: auto; }
.tree-panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }

133
frontend/src/views/DocUpload.vue

@ -9,11 +9,13 @@
v-model="uploadFolderId"
:data="folderTreeData"
:keys="{ value: 'value', label: 'label', children: 'children' }"
:disabled="!uploadCategory"
clearable
filterable
placeholder="选择目录(可选,默认分类根)"
placeholder="请先选择分类"
size="small"
style="width:240px;"
@change="onFolderChange"
/>
<div class="tag-input-wrapper">
<t-tag v-for="(tag, idx) in tagList" :key="idx" closable size="small" @close="removeTag(idx)">{{ tag }}</t-tag>
@ -50,13 +52,13 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, watch, h } from 'vue'
import { DialogPlugin } from 'tdesign-vue-next'
import type { UploadFile } from 'tdesign-vue-next'
import { useCategoryStore } from '@/stores/category'
import { useDocumentStore } from '@/stores/document'
import { uploadFile, uploadMarkdown, uploadJsonBasic, uploadFolder } from '@/api/upload'
import { getFolderList } from '@/api/folder'
import { getCategoryTree } from '@/api/category'
import { getFolderTree } from '@/api/folder'
import { toast } from '@/utils/toast'
const categoryStore = useCategoryStore()
@ -99,9 +101,9 @@ function handleTagBackspace() {
if (!tagInputValue.value && tagList.value.length > 0) removeTag(tagList.value.length - 1)
}
/** 去掉 'folder:' 前缀取真实目录 id */
/** 去掉 'folder:' 前缀取真实目录 id;非目录节点(如分类)返回空串,避免误传 */
function stripFolderPrefix(value: string): string {
return value.replace(/^folder:/, '')
return value.startsWith('folder:') ? value.slice('folder:'.length) : ''
}
/**
@ -112,7 +114,10 @@ function buildFormData(file: File, includeFolder = false): FormData {
const fd = new FormData()
fd.append('file', file)
if (uploadCategory.value) fd.append('categoryId', uploadCategory.value)
if (includeFolder && uploadFolderId.value) fd.append('folderId', stripFolderPrefix(uploadFolderId.value))
if (includeFolder) {
const fid = stripFolderPrefix(uploadFolderId.value)
if (fid) fd.append('folderId', fid)
}
if (tagList.value.length) fd.append('tags', tagList.value.join(','))
return fd
}
@ -183,62 +188,82 @@ async function uploadText() {
}
// ==================== t-tree-select ====================
/** 组装「分类 → 目录」可选树:分类节点禁用仅作分组,目录节点可选中 */
function buildFolderSelectTree(cats: any[], folders: any[]): any[] {
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)
}
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
}
function folderNode(f: any): any {
return { value: 'folder:' + f.id, label: f.name, children: (f.children || []).map(folderNode) }
}
function categoryNode(c: any): any {
// = =
/** 目录节点 → tree-select 选项 */
function folderNodeToOption(n: any): any {
return {
value: 'cat:' + c.id,
label: c.name,
disabled: true,
children: [
...buildFolderTree(byCat.get(String(c.id)) || []).map(folderNode),
...(c.children || []).map(categoryNode),
],
value: 'folder:' + n.id,
label: n.name,
categoryId: n.categoryId,
children: (n.children || []).map(folderNodeToOption),
}
}
return (cats || []).map(categoryNode)
}
/** 加载当前分类下的目录树;未选分类时目录选择器为空/禁用 */
async function loadFolderTree() {
const catId = uploadCategory.value
if (!catId) { folderTreeData.value = []; return }
try {
const [catRes, folderRes] = await Promise.all([getCategoryTree(), getFolderList()])
const cats = catRes.success ? (catRes.data || []) : []
const folders = folderRes.success ? (folderRes.data || []) : []
folderTreeData.value = buildFolderSelectTree(cats, folders)
const res = await getFolderTree(catId)
folderTreeData.value = res.success ? (res.data || []).map(folderNodeToOption) : []
} catch (e: any) {
toast('加载目录失败:' + e.message, 'error')
}
}
/** 分类变化 → 清空目录并刷新目录树(分类是目录的前提) */
watch(uploadCategory, () => {
uploadFolderId.value = ''
loadFolderTree()
})
/** 目录选中 → 回填分类,保持两者一致 */
function onFolderChange(_value: any, ctx: any) {
if (ctx?.trigger === 'check' && ctx?.data?.categoryId != null) {
uploadCategory.value = String(ctx.data.categoryId)
}
}
// ==================== ====================
function pickFolder() {
folderInput.value?.click()
}
/** 将 details 按失败原因分组,返回 [{ error, files: string[] }] */
function groupFailures(details: Array<{ file?: string; error?: string }> = []) {
const map = new Map<string, string[]>()
for (const d of details) {
const err = d?.error || '未知错误'
const file = d?.file || '未知文件'
if (!map.has(err)) map.set(err, [])
map.get(err)!.push(file)
}
return Array.from(map.entries()).map(([error, files]) => ({ error, files }))
}
/** 弹窗展示文件夹上传结果(成功/失败数 + 按原因分组的失败文件清单) */
function showFolderUploadResult(successCount: number, failCount: number, details: any[]) {
const groups = groupFailures(details)
const body = h('div', { style: 'max-height:320px;overflow-y:auto;' }, [
h('p', { style: 'margin:0 0 12px;' }, `成功 ${successCount} 个,失败 ${failCount}`),
...groups.map((g) => h('div', { style: 'margin-bottom:12px;' }, [
h('div', { style: 'color:var(--td-error-color);font-weight:600;margin-bottom:4px;' }, g.error),
h('ul', { style: 'margin:0;padding-left:18px;color:var(--td-text-color-secondary);font-size:12px;' },
g.files.slice(0, 10).map((f) => h('li', {}, f))),
g.files.length > 10
? h('div', { style: 'color:var(--td-text-color-placeholder);font-size:12px;' }, `…等 ${g.files.length} 个文件`)
: null,
])),
])
const dlg = DialogPlugin.alert({
header: '文件夹上传结果',
body,
confirmBtn: '知道了',
// onConfirm hide
onConfirm: () => { dlg.hide() },
})
}
async function onFolderSelected(e: Event) {
const input = e.target as HTMLInputElement
const list = Array.from(input.files || [])
@ -265,17 +290,14 @@ async function onFolderSelected(e: Event) {
if (skipped.length) toast(`已跳过 ${skipped.length} 个不支持的文件:${skipped.join('、')}`, 'warning')
if (valid.length === 0) { toast('所选文件夹内没有可上传的文件', 'error'); input.value = ''; return }
// FormDatafiles + relativePaths
// FormDatafiles + relativePaths
const fd = new FormData()
valid.forEach(f => fd.append('files', f))
const relativePaths: string[] = valid.map(f => {
const p = (f as any).webkitRelativePath || ''
const idx = p.indexOf('/')
return idx >= 0 ? p.slice(idx + 1) : f.name
})
const relativePaths: string[] = valid.map(f => (f as any).webkitRelativePath || f.name)
relativePaths.forEach(p => fd.append('relativePaths', p))
if (uploadCategory.value) fd.append('categoryId', uploadCategory.value)
if (uploadFolderId.value) fd.append('folderId', stripFolderPrefix(uploadFolderId.value))
const fid = stripFolderPrefix(uploadFolderId.value)
if (fid) fd.append('folderId', fid)
if (tagList.value.length) fd.append('tags', tagList.value.join(','))
folderUploading.value = true
@ -285,8 +307,10 @@ async function onFolderSelected(e: Event) {
if (r.success) {
const successCount = r.successCount ?? r.data?.successCount
const failCount = r.failCount ?? r.data?.failCount ?? 0
const details = r.data?.details ?? []
if (failCount > 0) {
toast(`文件夹上传完成:成功 ${successCount ?? '?'} 个,失败 ${failCount}`, 'warning')
showFolderUploadResult(successCount ?? 0, failCount, details)
} else {
toast(`文件夹上传完成:成功 ${successCount ?? valid.length}`, 'success')
}
@ -303,7 +327,6 @@ async function onFolderSelected(e: Event) {
}
}
onMounted(async () => { await loadFolderTree() })
</script>
<style scoped>

22
src/main/java/com/wok/supportbot/service/DocumentService.java

@ -172,12 +172,14 @@ public class DocumentService {
// 2. 使用保存的文件进行解析避免 MultipartFile 的临时文件问题
List<Document> documents = tikaDocumentReader.readFromFile(savedFile);
String fileType = getFileExtension(file.getOriginalFilename());
// 剥离路径前缀文件夹上传时浏览器 filename 携带相对路径含根目录名否则标题会带目录/前缀
String sourceName = stripPath(file.getOriginalFilename());
String fileType = getFileExtension(sourceName);
// 3. 创建文档记录并保存文件路径
return uploadDocument(documents,
title != null ? title : file.getOriginalFilename(),
file.getOriginalFilename(),
title != null ? title : sourceName,
sourceName,
fileType,
file.getSize(),
documents.get(0).getText(),
@ -1256,6 +1258,20 @@ public class DocumentService {
/**
* 获取文件扩展名
*/
/**
* 取文件名最后一段剥离可能的路径前缀
* 文件夹上传webkitdirectory时浏览器 filename 携带相对路径含根目录名
* 直接用作标题/源文件名会导致带目录/前缀
*/
private String stripPath(String filename) {
if (filename == null || filename.isEmpty()) {
return filename;
}
String normalized = filename.replace('\\', '/');
int idx = normalized.lastIndexOf('/');
return idx >= 0 ? normalized.substring(idx + 1) : normalized;
}
private String getFileExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return "unknown";

Loading…
Cancel
Save