Browse Source

feat(doc): 文档列表目录树默认收起并移除标签筛选

- 目录树去除 expand-all,初始仅显示分类、子目录折叠,点击展开
- 移除工具栏「全部标签」下拉及其数据源链路:filterTag/tagOptions、
  api 的 listDocuments tag 参数与 getTagList、store 的 tags/loadTags、
  路由守卫预加载
- 后端删除 /document/list 的 tag 参数过滤与 /tag/list 聚合接口
  (getTagList 实现一并移除)
- 文档自带 tags 属性、行内/详情展示、向量 metadata 均保留不变
Spring-AI-1.1.2
wanghanlin 1 week ago
parent
commit
a7d0ad87c9
  1. 10
      frontend/src/api/document.ts
  2. 1
      frontend/src/router/index.ts
  3. 14
      frontend/src/stores/document.ts
  4. 6
      frontend/src/views/DocList.vue
  5. 29
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  6. 43
      src/main/java/com/wok/supportbot/service/DocumentService.java

10
frontend/src/api/document.ts

@ -12,13 +12,12 @@ function authHeaders(): Record<string, string> {
// ==================== 文档 CRUD ==================== // ==================== 文档 CRUD ====================
/** 文档列表(分页 + 过滤 + 搜索) */ /** 文档列表(分页 + 过滤 + 搜索) */
export function listDocuments(page = 1, size = 10, categoryId?: string, status?: string, keyword?: string, tag?: string, folderId?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
export function listDocuments(page = 1, size = 10, categoryId?: string, status?: string, keyword?: string, folderId?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/document/list?page=${page}&size=${size}` let path = `/document/list?page=${page}&size=${size}`
if (categoryId) path += `&categoryId=${categoryId}` if (categoryId) path += `&categoryId=${categoryId}`
if (folderId != null) path += `&folderId=${folderId}` if (folderId != null) path += `&folderId=${folderId}`
if (status) path += `&status=${status}` if (status) path += `&status=${status}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (tag) path += `&tag=${encodeURIComponent(tag)}`
if (sortField) path += `&sortField=${sortField}` if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}` if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data) return request.get(path).then(r => r.data)
@ -128,10 +127,3 @@ export function searchDocuments(query: string, topK?: number, similarityThreshol
export function getStats(): Promise<ApiResponse> { export function getStats(): Promise<ApiResponse> {
return request.get('/document/stats').then(r => r.data) return request.get('/document/stats').then(r => r.data)
} }
// ==================== 标签 ====================
/** 获取标签列表 */
export function getTagList(): Promise<ApiResponse> {
return request.get('/tag/list').then(r => r.data)
}

1
frontend/src/router/index.ts

@ -62,7 +62,6 @@ router.beforeEach(async (to, _from, next) => {
await Promise.all([ await Promise.all([
categoryStore.loadCategories(), categoryStore.loadCategories(),
documentStore.loadStats(), documentStore.loadStats(),
documentStore.loadTags(),
]) ])
} catch { /* 预加载失败不阻塞导航 */ } } catch { /* 预加载失败不阻塞导航 */ }
break break

14
frontend/src/stores/document.ts

@ -1,13 +1,12 @@
/** /**
* store.js stats / tags
* store.js stats
*/ */
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { getStats, getTagList } from '@/api/document'
import { getStats } from '@/api/document'
export const useDocumentStore = defineStore('document', () => { export const useDocumentStore = defineStore('document', () => {
const stats = ref<any>(null) const stats = ref<any>(null)
const tags = ref<any[]>([])
async function loadStats(): Promise<void> { async function loadStats(): Promise<void> {
try { try {
@ -16,12 +15,5 @@ export const useDocumentStore = defineStore('document', () => {
} catch (e) { console.error('加载统计失败', e) } } catch (e) { console.error('加载统计失败', e) }
} }
async function loadTags(): Promise<void> {
try {
const res = await getTagList()
if (res.success) tags.value = res.data || []
} catch (e) { console.error('加载标签列表失败', e) }
}
return { stats, tags, loadStats, loadTags }
return { stats, loadStats }
}) })

6
frontend/src/views/DocList.vue

@ -15,7 +15,6 @@
:activable="true" :activable="true"
:active-multiple="false" :active-multiple="false"
hover hover
expand-all
empty="暂无分类与目录" empty="暂无分类与目录"
v-model:actived="activedKeys" v-model:actived="activedKeys"
@active="onTreeActive" @active="onTreeActive"
@ -44,7 +43,6 @@
<t-input v-model="keyword" placeholder="搜索文档标题或文件名..." clearable @change="debouncedLoad" style="max-width:260px;" size="small" /> <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="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-select v-model="filterStatus" :options="statusOptions" placeholder="全部状态" clearable size="small" style="width:110px;" @change="load()" />
<t-select v-model="filterTag" :options="tagOptions" placeholder="全部标签" clearable size="small" style="width:140px;" @change="load()" />
<t-button variant="outline" size="small" @click="load()">刷新</t-button> <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="hasProcessing" theme="warning" size="small"> 有文档处理中...</t-tag>
<t-tag v-if="filterFolder" theme="primary" variant="light" size="small" :closable="true" @close="clearFolderFilter">📁 目录筛选</t-tag> <t-tag v-if="filterFolder" theme="primary" variant="light" size="small" :closable="true" @close="clearFolderFilter">📁 目录筛选</t-tag>
@ -172,7 +170,6 @@ const loading = ref(false)
const filterCategory = ref('') const filterCategory = ref('')
const filterStatus = ref('') const filterStatus = ref('')
const keyword = ref('') const keyword = ref('')
const filterTag = ref('')
const selectedIds = ref(new Set<string>()) const selectedIds = ref(new Set<string>())
const hasProcessing = ref(false) const hasProcessing = ref(false)
@ -247,7 +244,6 @@ const columns = [
// ==================== ==================== // ==================== ====================
const categoryOptions = computed(() => [{ label: '全部分类', value: '' }, ...categoryStore.categories.map(c => ({ label: c.name, value: String(c.id) }))]) 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 statusOptions = [{ label: '全部状态', value: '' }, { label: '已完成', value: 'READY' }, { label: '处理中', value: 'PROCESSING' }, { label: '失败', value: 'FAILED' }]
const tagOptions = computed(() => [{ label: '全部标签', value: '' }, ...(documentStore.tags || []).map((t: any) => ({ label: `${t.tag} (${t.count})`, value: t.tag }))])
// //
const moveTreeData = computed(() => { const moveTreeData = computed(() => {
const hasUnclassified = treeData.value.some(n => n.value === 'cat:0') const hasUnclassified = treeData.value.some(n => n.value === 'cat:0')
@ -519,7 +515,7 @@ async function loadData(p = page.value) {
page.value = p page.value = p
loading.value = true loading.value = true
try { try {
const json = await listDocuments(p, pageSize.value, filterCategory.value || undefined, filterStatus.value || undefined, keyword.value.trim() || undefined, filterTag.value || undefined, filterFolder.value || undefined, sortInfo.value?.sortBy || undefined, sortInfo.value ? (sortInfo.value.descending ? 'desc' : 'asc') : undefined)
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) { if (json.success) {
documents.value = (json.data || []).map((d: any) => ({ ...d, _enabled: d.enabled !== false })) documents.value = (json.data || []).map((d: any) => ({ ...d, _enabled: d.enabled !== false }))
total.value = json.total || 0 total.value = json.total || 0

29
src/main/java/com/wok/supportbot/controller/DocumentController.java

@ -329,7 +329,7 @@ public class DocumentController {
} }
/** /**
* 查询文档列表分页 + 过滤 + 关键词搜索 + 标签筛选
* 查询文档列表分页 + 过滤 + 关键词搜索
* *
* @param page 页码默认1 * @param page 页码默认1
* @param size 每页大小默认10 * @param size 每页大小默认10
@ -337,7 +337,6 @@ public class DocumentController {
* @param folderId 目录ID过滤可选0 表示只看根目录 * @param folderId 目录ID过滤可选0 表示只看根目录
* @param status 状态过滤PROCESSING/READY/FAILED可选 * @param status 状态过滤PROCESSING/READY/FAILED可选
* @param keyword 关键词搜索模糊匹配标题和文件名可选 * @param keyword 关键词搜索模糊匹配标题和文件名可选
* @param tag 标签筛选精确匹配可选
* @return 分页文档列表 * @return 分页文档列表
*/ */
@GetMapping("/document/list") @GetMapping("/document/list")
@ -349,11 +348,10 @@ public class DocumentController {
@RequestParam(required = false) Long folderId, @RequestParam(required = false) Long folderId,
@RequestParam(required = false) String status, @RequestParam(required = false) String status,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) String tag,
@RequestParam(required = false) String sortField, @RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) { @RequestParam(required = false) String sortOrder) {
try { try {
Map<String, Object> result = documentService.listDocuments(page, size, categoryId, folderId, status, keyword, tag, sortField, sortOrder);
Map<String, Object> result = documentService.listDocuments(page, size, categoryId, folderId, status, keyword, sortField, sortOrder);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("success", true); data.put("success", true);
data.put("data", result.get("records")); data.put("data", result.get("records"));
@ -727,29 +725,6 @@ public class DocumentController {
return categoryFilter.parse(rawCategoryIds); return categoryFilter.parse(rawCategoryIds);
} }
// ==================== 标签管理 ====================
/**
* P1-2.3: 获取标签列表从所有文档的 tags 聚合去重含使用次数
*/
@GetMapping("/tag/list")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> getTagList() {
try {
List<Map<String, Object>> tags = documentService.getTagList();
return ResponseEntity.ok(Map.of(
"success", true,
"data", tags,
"total", tags.size()
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "获取标签列表失败:" + e.getMessage()
));
}
}
// ==================== 分块管理 ==================== // ==================== 分块管理 ====================
/** /**

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

@ -457,10 +457,10 @@ public class DocumentService {
// ==================== 文档管理 ==================== // ==================== 文档管理 ====================
/** /**
* 分页查询文档列表手动分页支持关键词搜索 + 标签筛选 + 目录筛选
* 分页查询文档列表手动分页支持关键词搜索 + 目录筛选
*/ */
public Map<String, Object> listDocuments(int page, int size, Long categoryId, Long folderId, String status, public Map<String, Object> listDocuments(int page, int size, Long categoryId, Long folderId, String status,
String keyword, String tag, String sortField, String sortOrder) {
String keyword, String sortField, String sortOrder) {
// 参数安全校验 // 参数安全校验
if (page < 1) page = 1; if (page < 1) page = 1;
if (size < 1 || size > 100) size = 10; if (size < 1 || size > 100) size = 10;
@ -482,16 +482,6 @@ public class DocumentService {
if (kw != null) { if (kw != null) {
countWrapper.and(w -> w.like("title", kw).or().like("source_name", kw)); countWrapper.and(w -> w.like("title", kw).or().like("source_name", kw));
} }
// P1-2.3: 标签筛选JSONB 包含查询使用 ObjectMapper 构建合法 JSON
if (tag != null && !tag.trim().isEmpty()) {
try {
ObjectMapper om = new ObjectMapper();
String jsonArray = om.writeValueAsString(List.of(tag.trim()));
countWrapper.apply("tags->'tags' @> {0}::jsonb", jsonArray);
} catch (Exception e) {
log.warn("构建标签筛选 JSON 失败: tag={}", tag, e);
}
}
// 先查询总数不加 ORDER BY // 先查询总数不加 ORDER BY
Long total = documentMapper.selectCount(countWrapper); Long total = documentMapper.selectCount(countWrapper);
@ -510,15 +500,6 @@ public class DocumentService {
if (kw != null) { if (kw != null) {
listWrapper.and(w -> w.like("title", kw).or().like("source_name", kw)); listWrapper.and(w -> w.like("title", kw).or().like("source_name", kw));
} }
if (tag != null && !tag.trim().isEmpty()) {
try {
ObjectMapper om = new ObjectMapper();
String jsonArray = om.writeValueAsString(List.of(tag.trim()));
listWrapper.apply("tags->'tags' @> {0}::jsonb", jsonArray);
} catch (Exception e) {
log.warn("构建标签筛选 JSON 失败: tag={}", tag, e);
}
}
// 排序字段白名单前端 colKey -> 数据库列名 SQL 注入 // 排序字段白名单前端 colKey -> 数据库列名 SQL 注入
Map<String, String> sortColumns = Map.of( Map<String, String> sortColumns = Map.of(
"title", "title", "title", "title",
@ -867,26 +848,6 @@ public class DocumentService {
} }
} }
// ==================== 标签管理 ====================
/**
* P1-2.3: 获取标签列表从所有文档的 tags JSONB 聚合去重含使用次数
*
* @return [{tag: "标签名", count: 使用次数}]
*/
public List<Map<String, Object>> getTagList() {
String sql = "SELECT tag_value AS tag, COUNT(*) AS count " +
"FROM knowledge_document, jsonb_array_elements(tags->'tags') AS tag_value " +
"WHERE is_delete = false AND tags IS NOT NULL AND tags->'tags' IS NOT NULL " +
"GROUP BY tag_value ORDER BY count DESC, tag_value ASC";
try {
return jdbcTemplate.queryForList(sql);
} catch (Exception e) {
log.warn("获取标签列表失败", e);
return List.of();
}
}
// ==================== 分块管理 ==================== // ==================== 分块管理 ====================
/** /**

Loading…
Cancel
Save