Browse Source

知识库文档管理 P0 阶段功能完善与体验优化

- 新增文档关键词搜索(title + source_name LIKE 查询,前端 300ms 防抖)
  - 文档列表增加文件大小(formatBytes)和分类名称列
  - 分类管理支持编辑弹窗、树形层级展示(最多3级)、创建时选择父分类
  - 新建 DocumentProcessingService 异步处理服务,上传/重处理改为同步插入
    PROCESSING 记录 + 异步执行,前端自动轮询状态
  - 修复分页参数越界、LIKE 通配符未转义、异步删除竞态条件等安全问题
  - 修复全选 checkbox 始终选中、分类排序方向不一致、文本上传缺少认证头等缺陷
  - 移除 DocUpload.js 遗留调试日志
dev-mcp
wanghanlin 4 weeks ago
parent
commit
e5cf51c507
  1. 22
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  2. 194
      src/main/java/com/wok/supportbot/service/DocumentProcessingService.java
  3. 120
      src/main/java/com/wok/supportbot/service/DocumentService.java
  4. 300
      src/main/resources/static/components/CategoryManager.js
  5. 102
      src/main/resources/static/components/DocList.js
  6. 29
      src/main/resources/static/components/DocUpload.js
  7. 7
      src/main/resources/static/js/api.js

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

@ -86,7 +86,7 @@ public class DocumentController {
KnowledgeDocument doc = documentService.uploadFile(file, title, categoryId, tags); KnowledgeDocument doc = documentService.uploadFile(file, title, categoryId, tags);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "文件上传并向量化成功",
"message", "文件上传成功,正在后台处理",
"data", doc "data", doc
)); ));
} catch (Exception e) { } catch (Exception e) {
@ -117,7 +117,7 @@ public class DocumentController {
KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, tags); KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, tags);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "文本内容上传并向量化成功",
"message", "文本内容上传成功,正在后台处理",
"data", doc "data", doc
)); ));
} catch (Exception e) { } catch (Exception e) {
@ -143,7 +143,7 @@ public class DocumentController {
KnowledgeDocument doc = documentService.uploadMarkdown(file, title, categoryId, tags); KnowledgeDocument doc = documentService.uploadMarkdown(file, title, categoryId, tags);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "Markdown文件上传并向量化成功",
"message", "Markdown文件上传成功,正在后台处理",
"data", doc "data", doc
)); ));
} catch (Exception e) { } catch (Exception e) {
@ -169,7 +169,7 @@ public class DocumentController {
KnowledgeDocument doc = documentService.uploadJsonBasic(file, title, categoryId, tags); KnowledgeDocument doc = documentService.uploadJsonBasic(file, title, categoryId, tags);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "JSON文件(基本方式)上传并向量化成功",
"message", "JSON文件上传成功,正在后台处理",
"data", doc "data", doc
)); ));
} catch (Exception e) { } catch (Exception e) {
@ -196,7 +196,7 @@ public class DocumentController {
KnowledgeDocument doc = documentService.uploadJsonFields(file, fields, title, categoryId, tags); KnowledgeDocument doc = documentService.uploadJsonFields(file, fields, title, categoryId, tags);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "JSON文件(按字段)上传并向量化成功",
"message", "JSON文件(按字段)上传成功,正在后台处理",
"data", doc, "data", doc,
"extractedFields", fields "extractedFields", fields
)); ));
@ -224,7 +224,7 @@ public class DocumentController {
KnowledgeDocument doc = documentService.uploadJsonPointer(file, pointer, title, categoryId, tags); KnowledgeDocument doc = documentService.uploadJsonPointer(file, pointer, title, categoryId, tags);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "JSON文件(按指针)上传并向量化成功",
"message", "JSON文件(按指针)上传成功,正在后台处理",
"data", doc, "data", doc,
"pointer", pointer "pointer", pointer
)); ));
@ -239,12 +239,13 @@ public class DocumentController {
// ==================== 文档管理 ==================== // ==================== 文档管理 ====================
/** /**
* 查询文档列表分页 + 过滤
* 查询文档列表分页 + 过滤 + 关键词搜索
* *
* @param page 页码默认1 * @param page 页码默认1
* @param size 每页大小默认10 * @param size 每页大小默认10
* @param categoryId 分类ID过滤可选 * @param categoryId 分类ID过滤可选
* @param status 状态过滤PROCESSING/READY/FAILED可选 * @param status 状态过滤PROCESSING/READY/FAILED可选
* @param keyword 关键词搜索模糊匹配标题和文件名可选
* @return 分页文档列表 * @return 分页文档列表
*/ */
@GetMapping("/document/list") @GetMapping("/document/list")
@ -253,9 +254,10 @@ public class DocumentController {
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String status) {
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
try { try {
Map<String, Object> result = documentService.listDocuments(page, size, categoryId, status);
Map<String, Object> result = documentService.listDocuments(page, size, categoryId, status, keyword);
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"));
@ -452,7 +454,7 @@ public class DocumentController {
KnowledgeDocument doc = documentService.reprocessDocument(id); KnowledgeDocument doc = documentService.reprocessDocument(id);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "重新处理成功",
"message", "已提交重新处理,正在后台执行",
"data", doc "data", doc
)); ));
} catch (Exception e) { } catch (Exception e) {

194
src/main/java/com/wok/supportbot/service/DocumentProcessingService.java

@ -0,0 +1,194 @@
package com.wok.supportbot.service;
import com.wok.supportbot.dao.KnowledgeDocumentMapper;
import com.wok.supportbot.document.transform.MyKeywordEnricher;
import com.wok.supportbot.document.transform.MyTokenTextSplitter;
import com.wok.supportbot.entity.KnowledgeDocument;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 文档异步处理服务
* 负责文档的分块关键词提取向量化等耗时操作在后台线程中执行
*/
@Service
@Slf4j
public class DocumentProcessingService {
@Autowired
private KnowledgeDocumentMapper documentMapper;
@Autowired
private MyTokenTextSplitter myTokenTextSplitter;
@Autowired
private MyKeywordEnricher myKeywordEnricher;
@Autowired
private VectorStore pgVectorVectorStore;
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 异步处理文档分块 关键词提取 向量化 更新状态
* 使用 REQUIRES_NEW 在独立事务中执行避免与主事务冲突
*
* @param docId 文档ID
* @param documents 已解析的原始文档列表
* @param sourceName 源文件名
* @param title 文档标题
* @param categoryId 分类ID
* @param tags 标签列表
*/
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void processDocumentAsync(Long docId, List<Document> documents, String sourceName,
String title, Long categoryId, List<String> tags) {
// 等待主事务提交确保文档记录可见最多重试 5 每次 200ms
KnowledgeDocument doc = waitForDocument(docId);
if (doc == null) {
log.error("异步处理文档失败:文档不存在(等待超时), id={}", docId);
return;
}
try {
// 1. 分块处理
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents);
// 2. 为每个分块设置 metadata
for (int i = 0; i < splitDocuments.size(); i++) {
Document d = splitDocuments.get(i);
Map<String, Object> meta = new HashMap<>(d.getMetadata());
meta.put("documentId", String.valueOf(docId));
meta.put("chunkIndex", i);
meta.put("sourceName", sourceName);
meta.put("title", title);
if (categoryId != null && categoryId > 0) {
meta.put("categoryId", String.valueOf(categoryId));
}
if (tags != null && !tags.isEmpty()) {
meta.put("tags", tags);
}
splitDocuments.set(i, new Document(d.getId(), d.getText(), meta));
}
// 3. 关键词提取
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments);
// 4. 向量化存储
pgVectorVectorStore.add(enrichedDocuments);
// 5. 更新状态为 READY
doc.setStatus("READY");
doc.setChunkCount(enrichedDocuments.size());
documentMapper.updateById(doc);
log.info("异步处理文档完成: id={}, title={}, chunks={}", docId, title, enrichedDocuments.size());
} catch (Exception e) {
doc.setStatus("FAILED");
doc.setErrorMessage(e.getMessage());
documentMapper.updateById(doc);
log.error("异步处理文档失败: id={}, title={}", docId, title, e);
}
}
/**
* 异步重新处理文档重新分块 + 向量化
*
* @param docId 文档ID
* @param documents 解析后的文档列表
*/
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void reprocessDocumentAsync(Long docId, List<Document> documents) {
// 等待主事务提交确保文档记录可见
KnowledgeDocument doc = waitForDocument(docId);
if (doc == null) {
log.error("异步重新处理文档失败:文档不存在(等待超时), id={}", docId);
return;
}
try {
// 删除旧向量
String sql = "SELECT id::text FROM vector_store WHERE metadata->>'documentId' = ?";
List<String> oldIds = jdbcTemplate.queryForList(sql, String.class, String.valueOf(docId));
if (!oldIds.isEmpty()) {
pgVectorVectorStore.delete(oldIds);
}
// 重新分块
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents);
for (int i = 0; i < splitDocuments.size(); i++) {
Document d = splitDocuments.get(i);
Map<String, Object> meta = new HashMap<>(d.getMetadata());
meta.put("documentId", String.valueOf(docId));
meta.put("chunkIndex", i);
meta.put("sourceName", doc.getSourceName());
meta.put("title", doc.getTitle());
if (doc.getCategoryId() != null && doc.getCategoryId() > 0) {
meta.put("categoryId", String.valueOf(doc.getCategoryId()));
}
if (doc.getTags() != null && doc.getTags().containsKey("tags")) {
meta.put("tags", doc.getTags().get("tags"));
}
splitDocuments.set(i, new Document(d.getId(), d.getText(), meta));
}
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments);
pgVectorVectorStore.add(enrichedDocuments);
doc.setStatus("READY");
doc.setChunkCount(enrichedDocuments.size());
doc.setErrorMessage(null);
documentMapper.updateById(doc);
log.info("异步重新处理文档成功: id={}, title={}, chunks={}", docId, doc.getTitle(), enrichedDocuments.size());
} catch (Exception e) {
doc.setStatus("FAILED");
doc.setErrorMessage(e.getMessage());
documentMapper.updateById(doc);
log.error("异步重新处理文档失败: id={}, title={}", docId, doc.getTitle(), e);
}
}
/**
* 等待主事务提交后文档可见最多重试 5 每次 200ms总计 1
* 解决 @Async 任务可能在主事务提交前启动的竞态条件
* 同时检查文档是否已被删除用户可能在异步处理前删除了文档
*/
private KnowledgeDocument waitForDocument(Long docId) {
for (int i = 0; i < 5; i++) {
KnowledgeDocument doc = documentMapper.selectById(docId);
if (doc != null) {
// 检查是否已被逻辑删除用户可能在处理前删除了文档
if (doc.isDelete()) {
log.info("文档已被删除,跳过异步处理: id={}", docId);
return null;
}
return doc;
}
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
return null;
}
}

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

@ -68,10 +68,15 @@ public class DocumentService {
@Autowired @Autowired
private JsonDocumentLoader jsonDocumentLoader; private JsonDocumentLoader jsonDocumentLoader;
@Autowired
private DocumentProcessingService documentProcessingService;
// ==================== 文档上传 ==================== // ==================== 文档上传 ====================
/** /**
* 统一文档上传流程创建记录 -> 分块 -> 关键词 -> 向量化 -> 更新状态
* 统一文档上传流程创建记录PROCESSING 触发异步处理
* 同步阶段内容去重检查 + 创建 PROCESSING 记录
* 异步阶段分块 关键词提取 向量化 更新状态为 READY
* *
* @param documents 解析后的文档列表 * @param documents 解析后的文档列表
* @param title 文档标题 * @param title 文档标题
@ -81,7 +86,7 @@ public class DocumentService {
* @param content 原文内容截断预览 * @param content 原文内容截断预览
* @param categoryId 分类ID * @param categoryId 分类ID
* @param tags 标签列表 * @param tags 标签列表
* @return 创建完成的文档记录
* @return 创建完成的文档记录status=PROCESSING
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public KnowledgeDocument uploadDocument(List<Document> documents, String title, String sourceName, public KnowledgeDocument uploadDocument(List<Document> documents, String title, String sourceName,
@ -111,49 +116,11 @@ public class DocumentService {
.build(); .build();
documentMapper.insert(docRecord); documentMapper.insert(docRecord);
try {
// 2. 分块处理
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents);
// 3. 为每个分块设置 documentId 等元数据
for (int i = 0; i < splitDocuments.size(); i++) {
Document doc = splitDocuments.get(i);
Map<String, Object> meta = new HashMap<>(doc.getMetadata());
meta.put("documentId", String.valueOf(docRecord.getId()));
meta.put("chunkIndex", i);
meta.put("sourceName", sourceName);
meta.put("title", title != null ? title : sourceName);
if (categoryId != null) {
meta.put("categoryId", String.valueOf(categoryId));
}
if (tags != null && !tags.isEmpty()) {
meta.put("tags", tags);
}
splitDocuments.set(i, new Document(doc.getId(), doc.getText(), meta));
}
// 4. 关键词提取
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments);
// 5. 向量化存储
pgVectorVectorStore.add(enrichedDocuments);
// 6. 更新文档状态为 READY
docRecord.setStatus("READY");
docRecord.setChunkCount(enrichedDocuments.size());
documentMapper.updateById(docRecord);
log.info("文档上传成功: id={}, title={}, chunks={}", docRecord.getId(), docRecord.getTitle(), enrichedDocuments.size());
} catch (Exception e) {
// 标记为失败
docRecord.setStatus("FAILED");
docRecord.setErrorMessage(e.getMessage());
documentMapper.updateById(docRecord);
log.error("文档上传失败: id={}, title={}", docRecord.getId(), docRecord.getTitle(), e);
throw new RuntimeException("文档处理失败: " + e.getMessage(), e);
}
// 2. 触发异步处理分块 关键词 向量化 更新状态
documentProcessingService.processDocumentAsync(
docRecord.getId(), documents, sourceName, title, categoryId, tags);
log.info("文档已创建,后台处理中: id={}, title={}", docRecord.getId(), docRecord.getTitle());
return docRecord; return docRecord;
} }
@ -249,9 +216,16 @@ public class DocumentService {
// ==================== 文档管理 ==================== // ==================== 文档管理 ====================
/** /**
* 分页查询文档列表手动分页
* 分页查询文档列表手动分页支持关键词搜索
*/ */
public Map<String, Object> listDocuments(int page, int size, Long categoryId, String status) {
public Map<String, Object> listDocuments(int page, int size, Long categoryId, String status, String keyword) {
// 参数安全校验
if (page < 1) page = 1;
if (size < 1 || size > 100) size = 10;
// LIKE 通配符转义
String kw = (keyword != null && !keyword.trim().isEmpty())
? keyword.trim().replace("%", "\\%").replace("_", "\\_") : null;
// 构建基础条件用于 count list // 构建基础条件用于 count list
QueryWrapper<KnowledgeDocument> countWrapper = new QueryWrapper<>(); QueryWrapper<KnowledgeDocument> countWrapper = new QueryWrapper<>();
if (categoryId != null && categoryId > 0) { if (categoryId != null && categoryId > 0) {
@ -260,6 +234,9 @@ public class DocumentService {
if (status != null && !status.isEmpty()) { if (status != null && !status.isEmpty()) {
countWrapper.eq("status", status); countWrapper.eq("status", status);
} }
if (kw != null) {
countWrapper.and(w -> w.like("title", kw).or().like("source_name", kw));
}
// 先查询总数不加 ORDER BY // 先查询总数不加 ORDER BY
Long total = documentMapper.selectCount(countWrapper); Long total = documentMapper.selectCount(countWrapper);
@ -272,6 +249,9 @@ public class DocumentService {
if (status != null && !status.isEmpty()) { if (status != null && !status.isEmpty()) {
listWrapper.eq("status", status); listWrapper.eq("status", status);
} }
if (kw != null) {
listWrapper.and(w -> w.like("title", kw).or().like("source_name", kw));
}
listWrapper.orderByDesc("create_time"); listWrapper.orderByDesc("create_time");
listWrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size); listWrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size);
List<KnowledgeDocument> records = documentMapper.selectList(listWrapper); List<KnowledgeDocument> records = documentMapper.selectList(listWrapper);
@ -422,7 +402,7 @@ public class DocumentService {
} }
/** /**
* 重新处理文档重新分块 + 向量化
* 重新处理文档异步先标记为 PROCESSING后台重新分块 + 向量化
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public KnowledgeDocument reprocessDocument(Long id) { public KnowledgeDocument reprocessDocument(Long id) {
@ -434,53 +414,17 @@ public class DocumentService {
throw new RuntimeException("文档无内容,无法重新处理"); throw new RuntimeException("文档无内容,无法重新处理");
} }
// 删除旧向量
deleteVectorsByDocumentId(String.valueOf(id));
// 重新解析并处理
List<Document> documents = simpleStringDocumentReader.read(doc.getContent());
// 标记为 PROCESSING清空旧状态
doc.setStatus("PROCESSING"); doc.setStatus("PROCESSING");
doc.setChunkCount(0); doc.setChunkCount(0);
doc.setErrorMessage(null); doc.setErrorMessage(null);
documentMapper.updateById(doc); documentMapper.updateById(doc);
try {
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents);
for (int i = 0; i < splitDocuments.size(); i++) {
Document d = splitDocuments.get(i);
Map<String, Object> meta = new HashMap<>(d.getMetadata());
meta.put("documentId", String.valueOf(doc.getId()));
meta.put("chunkIndex", i);
meta.put("sourceName", doc.getSourceName());
meta.put("title", doc.getTitle());
if (doc.getCategoryId() != null && doc.getCategoryId() > 0) {
meta.put("categoryId", String.valueOf(doc.getCategoryId()));
}
if (doc.getTags() != null && doc.getTags().containsKey("tags")) {
meta.put("tags", doc.getTags().get("tags"));
}
splitDocuments.set(i, new Document(d.getId(), d.getText(), meta));
}
List<Document> enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments);
pgVectorVectorStore.add(enrichedDocuments);
doc.setStatus("READY");
doc.setChunkCount(enrichedDocuments.size());
documentMapper.updateById(doc);
log.info("重新处理文档成功: id={}, title={}, chunks={}", doc.getId(), doc.getTitle(), enrichedDocuments.size());
} catch (Exception e) {
doc.setStatus("FAILED");
doc.setErrorMessage(e.getMessage());
documentMapper.updateById(doc);
log.error("重新处理文档失败: id={}, title={}", doc.getId(), doc.getTitle(), e);
throw new RuntimeException("重新处理失败: " + e.getMessage(), e);
}
// 解析内容并触发异步处理
List<Document> documents = simpleStringDocumentReader.read(doc.getContent());
documentProcessingService.reprocessDocumentAsync(id, documents);
log.info("文档重新处理已提交: id={}, title={}", doc.getId(), doc.getTitle());
return doc; return doc;
} }

300
src/main/resources/static/components/CategoryManager.js

@ -1,53 +1,145 @@
/** /**
* 🏷 分类管理 * 🏷 分类管理
* P0-3: 支持编辑 + 树形层级展示 + 创建时选择父分类
*/ */
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { store } from '../js/store.js' import { store } from '../js/store.js'
import { createCategory, deleteCategory } from '../js/api.js'
import { createCategory, updateCategory, deleteCategory } from '../js/api.js'
import { toast } from '../js/utils.js' import { toast } from '../js/utils.js'
export default { export default {
template: ` template: `
<div class="card"> <div class="card">
<h2>分类管理</h2> <h2>分类管理</h2>
<div class="input-row">
<input class="input" v-model="name" placeholder="分类名称">
<input class="input" v-model="description" placeholder="分类描述(可选)">
<input class="input input-sm" v-model.number="sortOrder" placeholder="排序" type="number">
<button class="btn btn-success" @click="create">创建分类</button>
<!-- 创建新分类 -->
<div style="padding:12px;background:#fafafa;border-radius:8px;border:1px solid var(--border);margin-bottom:16px;">
<div style="font-weight:600;font-size:13px;margin-bottom:8px;">创建新分类</div>
<div class="input-row" style="margin-bottom:0;">
<select class="select" v-model="newParentId" style="max-width:180px;">
<option value="">顶级分类</option>
<option v-for="c in parentOptions" :key="c.id" :value="c.id">
{{ parentLabel(c) }}
</option>
</select>
<input class="input" v-model="newName" placeholder="分类名称" @keyup.enter="create">
<input class="input" v-model="newDescription" placeholder="描述(可选)">
<input class="input input-sm" v-model.number="newSortOrder" placeholder="排序" type="number" style="max-width:80px;">
<button class="btn btn-success" @click="create">创建</button>
</div>
</div>
<!-- 分类树列表 -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-size:13px;color:var(--sub);"> {{ store.categories.length }} 个分类</span>
<button class="btn btn-outline btn-sm" @click="store.loadCategories()">刷新</button> <button class="btn btn-outline btn-sm" @click="store.loadCategories()">刷新</button>
</div> </div>
<div v-if="store.categories.length === 0" style="margin-top:12px;font-size:13px;">暂无分类</div>
<div v-else style="margin-top:12px;font-size:13px;">
<div v-for="c in store.categories" :key="c.id" style="padding:8px 0;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<span><strong>{{ c.name }}</strong> <span style="color:var(--sub);font-size:12px;">{{ c.description || '' }}</span></span>
<button class="btn btn-danger btn-sm" @click="remove(c.id)">删除</button>
<div v-if="flatTree.length === 0" style="padding:20px;text-align:center;color:var(--sub);font-size:13px;">暂无分类请在上方创建</div>
<div v-else>
<div v-for="node in flatTree" :key="node.id"
style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;"
:style="{ paddingLeft: (node.level * 24 + 12) + 'px' }">
<!-- 左侧展开/折叠 + 名称 + 描述 -->
<div style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
<!-- 展开/折叠按钮 -->
<span v-if="node.hasChildren" @click="toggleExpand(node.id)"
style="cursor:pointer;user-select:none;font-size:12px;width:16px;text-align:center;color:var(--sub);">
{{ expandedIds.has(node.id) ? '▼' : '▶' }}
</span>
<span v-else style="width:16px;"></span>
<!-- 分类名称和描述 -->
<div style="min-width:0;">
<strong style="font-size:13px;">{{ node.name }}</strong>
<span v-if="node.description" style="color:var(--sub);font-size:12px;margin-left:8px;">{{ node.description }}</span>
<span style="color:var(--sub);font-size:11px;margin-left:8px;">
({{ node.documentCount || 0 }} 篇文档)
</span>
</div>
</div>
<!-- 右侧操作按钮 -->
<div style="display:flex;gap:4px;flex-shrink:0;">
<button class="btn btn-sm btn-outline" @click="openEdit(node)">编辑</button>
<button class="btn btn-sm btn-danger" @click="remove(node)">删除</button>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div v-if="editModal.visible" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:1000;" @click.self="closeEdit">
<div style="background:white;border-radius:12px;padding:24px;min-width:400px;max-width:500px;box-shadow:0 20px 60px rgba(0,0,0,0.15);">
<h3 style="margin:0 0 16px 0;font-size:16px;">编辑分类</h3>
<div style="display:flex;flex-direction:column;gap:12px;">
<div>
<label style="font-size:12px;color:var(--sub);display:block;margin-bottom:4px;">分类名称</label>
<input class="input" v-model="editModal.name" placeholder="分类名称" @keyup.enter="saveEdit">
</div>
<div>
<label style="font-size:12px;color:var(--sub);display:block;margin-bottom:4px;">描述</label>
<input class="input" v-model="editModal.description" placeholder="描述(可选)">
</div>
<div>
<label style="font-size:12px;color:var(--sub);display:block;margin-bottom:4px;">排序权重</label>
<input class="input input-sm" v-model.number="editModal.sortOrder" type="number" style="max-width:120px;">
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:20px;">
<button class="btn btn-outline" @click="closeEdit">取消</button>
<button class="btn btn-primary" @click="saveEdit">保存</button>
</div>
</div> </div>
</div> </div>
</div> </div>
`, `,
setup() { setup() {
const name = ref('')
const description = ref('')
const sortOrder = ref(0)
// ==================== 创建分类 ====================
const newName = ref('')
const newDescription = ref('')
const newParentId = ref('')
const newSortOrder = ref(0)
// 父分类选项(排除顶级,只显示可以成为父分类的节点,限制最多2级)
const parentOptions = computed(() => {
const opts = []
function addOptions(categories, level) {
for (const cat of categories) {
opts.push({ id: cat.id, name: cat.name, level })
if (cat.children && cat.children.length > 0 && level < 2) {
addOptions(cat.children, level + 1)
}
}
}
addOptions(categoryTree.value, 0)
return opts
})
// 父分类下拉显示文本(用 └ 前缀表示层级)
function parentLabel(c) {
return c.level > 0 ? '─'.repeat(c.level) + ' ' + c.name : c.name
}
async function create() { async function create() {
if (!name.value.trim()) {
if (!newName.value.trim()) {
toast('请输入分类名称', 'error') toast('请输入分类名称', 'error')
return return
} }
try { try {
const json = await createCategory({ const json = await createCategory({
name: name.value,
description: description.value,
sortOrder: sortOrder.value
name: newName.value.trim(),
description: newDescription.value.trim(),
parentId: newParentId.value || null,
sortOrder: newSortOrder.value
}) })
if (json.success) { if (json.success) {
toast('分类创建成功', 'success') toast('分类创建成功', 'success')
name.value = ''
description.value = ''
store.loadCategories()
newName.value = ''
newDescription.value = ''
newParentId.value = ''
newSortOrder.value = 0
await store.loadCategories()
} else { } else {
toast(json.message || '创建失败', 'error') toast(json.message || '创建失败', 'error')
} }
@ -56,13 +148,154 @@ export default {
} }
} }
async function remove(id) {
if (!confirm('确定删除此分类?关联文档将变为未分类')) return
// ==================== 树形展示 ====================
const expandedIds = ref(new Set())
// 构建分类树(从扁平列表构建层级结构)
const categoryTree = computed(() => {
const categories = store.categories || []
const nodeMap = new Map()
const roots = []
// 先创建所有节点
for (const cat of categories) {
nodeMap.set(cat.id, { ...cat, children: [], level: 0 })
}
// 构建父子关系
for (const node of nodeMap.values()) {
if (node.parentId && node.parentId !== '0' && node.parentId !== 0) {
const parent = nodeMap.get(node.parentId)
if (parent) {
parent.children.push(node)
} else {
roots.push(node)
}
} else {
roots.push(node)
}
}
// 按 sortOrder 升序排列(与后端一致)
function sortChildren(nodes) {
nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
for (const node of nodes) {
sortChildren(node.children)
}
}
sortChildren(roots)
return roots
})
// 展平树为列表(带层级信息)
const flatTree = computed(() => {
const result = []
function traverse(nodes, level) {
for (const node of nodes) {
result.push({
...node,
level,
hasChildren: node.children && node.children.length > 0
})
// 只展示已展开节点的子节点
if (node.children && node.children.length > 0 && expandedIds.value.has(node.id)) {
traverse(node.children, level + 1)
}
}
}
traverse(categoryTree.value, 0)
return result
})
function toggleExpand(id) {
const newSet = new Set(expandedIds.value)
if (newSet.has(id)) {
newSet.delete(id)
} else {
newSet.add(id)
}
expandedIds.value = newSet
}
// 初始时展开所有有子节点的分类
function expandAll() {
const ids = new Set()
function collectIds(nodes) {
for (const node of nodes) {
if (node.children && node.children.length > 0) {
ids.add(node.id)
collectIds(node.children)
}
}
}
collectIds(categoryTree.value)
expandedIds.value = ids
}
// 加载分类后自动展开
store.loadCategories().then(() => {
setTimeout(expandAll, 100)
})
// ==================== 编辑分类 ====================
const editModal = ref({
visible: false,
id: null,
name: '',
description: '',
sortOrder: 0
})
function openEdit(node) {
editModal.value = {
visible: true,
id: node.id,
name: node.name,
description: node.description || '',
sortOrder: node.sortOrder || 0
}
}
function closeEdit() {
editModal.value = { visible: false, id: null, name: '', description: '', sortOrder: 0 }
}
async function saveEdit() {
if (!editModal.value.name.trim()) {
toast('请输入分类名称', 'error')
return
}
try {
const json = await updateCategory(editModal.value.id, {
name: editModal.value.name.trim(),
description: editModal.value.description.trim(),
sortOrder: editModal.value.sortOrder
})
if (json.success) {
toast('分类更新成功', 'success')
closeEdit()
await store.loadCategories()
setTimeout(expandAll, 100)
} else {
toast(json.message || '更新失败', 'error')
}
} catch (e) {
toast('更新分类失败:' + e.message, 'error')
}
}
// ==================== 删除分类 ====================
async function remove(node) {
const childCount = countDescendants(node)
const childMsg = childCount > 0 ? `\n注意:此分类下有 ${childCount} 个子分类,将一并清空关联` : ''
if (!confirm(`确定删除分类"${node.name}"?关联文档将变为未分类${childMsg}`)) return
try { try {
const json = await deleteCategory(id)
const json = await deleteCategory(node.id)
if (json.success) { if (json.success) {
toast('分类已删除', 'success') toast('分类已删除', 'success')
store.loadCategories()
await store.loadCategories()
setTimeout(expandAll, 100)
} else { } else {
toast(json.message || '删除失败', 'error') toast(json.message || '删除失败', 'error')
} }
@ -71,6 +304,19 @@ export default {
} }
} }
return { name, description, sortOrder, store, create, remove }
function countDescendants(node) {
if (!node.children || node.children.length === 0) return 0
let count = node.children.length
for (const child of node.children) {
count += countDescendants(child)
}
return count
}
return {
newName, newDescription, newParentId, newSortOrder, parentOptions, parentLabel,
create, store, flatTree, expandedIds, toggleExpand,
editModal, openEdit, closeEdit, saveEdit, remove
}
} }
} }

102
src/main/resources/static/components/DocList.js

@ -1,16 +1,20 @@
/** /**
* 📋 文档列表 + 分页 + 批量操作 * 📋 文档列表 + 分页 + 批量操作
* P0-1: 关键词搜索标题/文件名模糊匹配
* P0-2: 文件大小列 + 分类名称列
* P0-4: 处理中状态自动轮询刷新
*/ */
import { ref } from 'vue'
import { ref, computed, onUnmounted } from 'vue'
import { store } from '../js/store.js' import { store } from '../js/store.js'
import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments } from '../js/api.js' import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments } from '../js/api.js'
import { toast, formatDate } from '../js/utils.js'
import { toast, formatDate, formatBytes } from '../js/utils.js'
export default { export default {
template: ` template: `
<div class="card"> <div class="card">
<h2>文档列表</h2> <h2>文档列表</h2>
<div class="input-row"> <div class="input-row">
<input class="input" v-model="keyword" placeholder="搜索文档标题或文件名..." @input="debouncedLoad" style="max-width:260px;">
<select class="select" v-model="filterCategory" @change="load()"> <select class="select" v-model="filterCategory" @change="load()">
<option value="">全部分类</option> <option value="">全部分类</option>
<option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option> <option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
@ -23,6 +27,9 @@ export default {
</select> </select>
<button class="btn btn-outline btn-sm" @click="load()">刷新</button> <button class="btn btn-outline btn-sm" @click="load()">刷新</button>
<!-- 处理中提示 -->
<span v-if="hasProcessing" style="font-size:12px;color:#d97706;font-weight:600;"> 有文档处理中...</span>
<!-- 批量操作按钮 --> <!-- 批量操作按钮 -->
<template v-if="selectedIds.size > 0"> <template v-if="selectedIds.size > 0">
<span style="font-size:12px;color:var(--text);font-weight:600;">已选 {{ selectedIds.size }} </span> <span style="font-size:12px;color:var(--text);font-weight:600;">已选 {{ selectedIds.size }} </span>
@ -37,9 +44,10 @@ export default {
<thead> <thead>
<tr> <tr>
<th><input type="checkbox" :checked="isAllSelected" @change="toggleSelectAll" style="cursor:pointer;"></th> <th><input type="checkbox" :checked="isAllSelected" @change="toggleSelectAll" style="cursor:pointer;"></th>
<th>ID</th>
<th>标题</th> <th>标题</th>
<th>类型</th> <th>类型</th>
<th>大小</th>
<th>分类</th>
<th>状态</th> <th>状态</th>
<th>分块数</th> <th>分块数</th>
<th>创建时间</th> <th>创建时间</th>
@ -48,18 +56,19 @@ export default {
</thead> </thead>
<tbody> <tbody>
<tr v-if="documents.length === 0"> <tr v-if="documents.length === 0">
<td colspan="8" style="text-align:center;color:var(--sub);">暂无文档</td>
<td colspan="9" style="text-align:center;color:var(--sub);">暂无文档</td>
</tr> </tr>
<tr v-for="d in documents" :key="d.id" :style="selectedIds.has(d.id) ? 'background:#f3f4f6;' : ''"> <tr v-for="d in documents" :key="d.id" :style="selectedIds.has(d.id) ? 'background:#f3f4f6;' : ''">
<td><input type="checkbox" :checked="selectedIds.has(d.id)" @change="toggleSelect(d.id)" style="cursor:pointer;"></td> <td><input type="checkbox" :checked="selectedIds.has(d.id)" @change="toggleSelect(d.id)" style="cursor:pointer;"></td>
<td>{{ d.id }}</td>
<td> <td>
<strong>{{ d.title }}</strong><br> <strong>{{ d.title }}</strong><br>
<span style="font-size:11px;color:var(--sub);">{{ d.sourceName || '' }}</span> <span style="font-size:11px;color:var(--sub);">{{ d.sourceName || '' }}</span>
</td> </td>
<td><span class="category-tag">{{ d.fileType }}</span></td> <td><span class="category-tag">{{ d.fileType }}</span></td>
<td><span :class="statusClass(d.status)">{{ d.status }}</span></td>
<td>{{ d.chunkCount }}</td>
<td style="white-space:nowrap;">{{ formatSize(d.fileSize) }}</td>
<td>{{ store.getCategoryName(d.categoryId) }}</td>
<td><span :class="statusClass(d.status)">{{ statusLabel(d.status) }}</span></td>
<td>{{ d.chunkCount || 0 }}</td>
<td>{{ formatDate(d.createTime) }}</td> <td>{{ formatDate(d.createTime) }}</td>
<td> <td>
<button class="btn btn-sm btn-outline" @click="viewDetail(d.id)">查看</button> <button class="btn btn-sm btn-outline" @click="viewDetail(d.id)">查看</button>
@ -89,29 +98,87 @@ export default {
const total = ref(0) const total = ref(0)
const filterCategory = ref('') const filterCategory = ref('')
const filterStatus = ref('') const filterStatus = ref('')
const keyword = ref('')
const selectedIds = ref(new Set()) const selectedIds = ref(new Set())
// 计算是否全选
const isAllSelected = () => {
return documents.value.length > 0 && documents.value.every(d => selectedIds.value.has(d.id))
// 防抖搜索
let debounceTimer = null
function debouncedLoad() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => load(1), 300)
} }
// 轮询控制
let pollTimer = null
const hasProcessing = ref(false)
function startPolling() {
stopPolling()
hasProcessing.value = true
pollTimer = setInterval(async () => {
await load(page.value)
if (!documents.value.some(d => d.status === 'PROCESSING')) {
stopPolling()
store.loadStats()
toast('所有文档处理完成', 'success')
}
}, 2000)
}
function stopPolling() {
hasProcessing.value = false
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
// 组件卸载时清理定时器
onUnmounted(() => {
stopPolling()
clearTimeout(debounceTimer)
})
// 计算是否全选(computed 确保响应式更新)
const isAllSelected = computed(() => {
return documents.value.length > 0 && documents.value.every(d => selectedIds.value.has(d.id))
})
function statusClass(status) { function statusClass(status) {
return status === 'READY' ? 'status-ready' return status === 'READY' ? 'status-ready'
: status === 'PROCESSING' ? 'status-processing' : status === 'PROCESSING' ? 'status-processing'
: 'status-failed' : 'status-failed'
} }
function statusLabel(status) {
return status === 'READY' ? '已完成'
: status === 'PROCESSING' ? '处理中'
: status === 'FAILED' ? '失败'
: status
}
function formatSize(bytes) {
if (!bytes || bytes === 0) return '-'
return formatBytes(bytes)
}
async function load(p = 1) { async function load(p = 1) {
page.value = p page.value = p
// 切换页面时清空选择 // 切换页面时清空选择
selectedIds.value = new Set() selectedIds.value = new Set()
try { try {
const json = await listDocuments(p, 10, filterCategory.value || undefined, filterStatus.value || undefined)
const json = await listDocuments(p, 10,
filterCategory.value || undefined,
filterStatus.value || undefined,
keyword.value.trim() || undefined)
if (json.success) { if (json.success) {
documents.value = json.data || [] documents.value = json.data || []
total.value = json.total || 0 total.value = json.total || 0
pages.value = json.pages || 1 pages.value = json.pages || 1
// 检查是否有处理中的文档,启动轮询
if (documents.value.some(d => d.status === 'PROCESSING')) {
if (!pollTimer) startPolling()
}
} else { } else {
toast(json.message || '查询失败', 'error') toast(json.message || '查询失败', 'error')
} }
@ -128,7 +195,7 @@ export default {
} }
function toggleSelectAll() { function toggleSelectAll() {
if (isAllSelected()) {
if (isAllSelected.value) {
selectedIds.value = new Set() selectedIds.value = new Set()
} else { } else {
const s = new Set() const s = new Set()
@ -166,8 +233,10 @@ export default {
try { try {
const json = await reprocessDocument(id) const json = await reprocessDocument(id)
if (json.success) { if (json.success) {
toast('重新处理成功', 'success')
toast(json.message || '已提交重新处理', 'success')
load(page.value) load(page.value)
// 触发轮询
if (!pollTimer) startPolling()
} else { } else {
toast(json.message || '重新处理失败', 'error') toast(json.message || '重新处理失败', 'error')
} }
@ -203,6 +272,8 @@ export default {
toast(json.message, 'success') toast(json.message, 'success')
selectedIds.value = new Set() selectedIds.value = new Set()
load(page.value) load(page.value)
// 触发轮询
if (!pollTimer) startPolling()
} else { } else {
toast(json.message || '批量重新处理失败', 'error') toast(json.message || '批量重新处理失败', 'error')
} }
@ -212,8 +283,9 @@ export default {
} }
return { return {
documents, page, pages, total, filterCategory, filterStatus,
selectedIds, store, statusClass, isAllSelected,
documents, page, pages, total, filterCategory, filterStatus, keyword,
selectedIds, store, statusClass, statusLabel, isAllSelected, hasProcessing,
formatSize, debouncedLoad,
load, toggleSelect, toggleSelectAll, clearSelection, load, toggleSelect, toggleSelectAll, clearSelection,
viewDetail, remove, reprocess, batchRemove, batchReprocess, formatDate viewDetail, remove, reprocess, batchRemove, batchReprocess, formatDate
} }

29
src/main/resources/static/components/DocUpload.js

@ -5,7 +5,7 @@
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import { store } from '../js/store.js' import { store } from '../js/store.js'
import { uploadFile, uploadString, uploadMarkdown, uploadJsonBasic, uploadJsonFields, uploadJsonPointer } from '../js/api.js' import { uploadFile, uploadString, uploadMarkdown, uploadJsonBasic, uploadJsonFields, uploadJsonPointer } from '../js/api.js'
import { toast, formatBytes } from '../js/utils.js'
import { toast, formatBytes, authHeaders, API_BASE } from '../js/utils.js'
// ==================== 上传校验常量 ==================== // ==================== 上传校验常量 ====================
@ -177,12 +177,9 @@ export default {
file: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: '' file: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: ''
}) })
// 添加调试用的计算属性,检查按钮状态
// 检查按钮是否应禁用
const getButtonState = (type) => { const getButtonState = (type) => {
const hasFile = !!fileData[type]
const hasError = !!validationErrors[type]
console.log(`[BUTTON STATE] type=${type}, hasFile=${hasFile}, hasError=${hasError}, disabled=${!hasFile || hasError}`)
return !hasFile || hasError
return !fileData[type] || !!validationErrors[type]
} }
const subTabs = [ const subTabs = [
@ -217,12 +214,9 @@ export default {
if (!input.files || input.files.length === 0) return if (!input.files || input.files.length === 0) return
const files = Array.from(input.files) const files = Array.from(input.files)
console.log('[DEBUG] handleFileSelect - type:', type, 'files:', files.length)
// 前端校验 // 前端校验
const error = validateFiles(files) const error = validateFiles(files)
console.log('[DEBUG] validation error:', error)
if (error) { if (error) {
// 校验不通过:清空数据,保持按钮禁用 // 校验不通过:清空数据,保持按钮禁用
validationErrors[type] = error validationErrors[type] = error
@ -240,10 +234,6 @@ export default {
? `已选择 <strong>${files.length}</strong> 个文件(共 ${formatBytes(totalSize)}` ? `已选择 <strong>${files.length}</strong> 个文件(共 ${formatBytes(totalSize)}`
: `已选择:<strong>${files[0].name}</strong> (${formatBytes(files[0].size)})` : `已选择:<strong>${files[0].name}</strong> (${formatBytes(files[0].size)})`
fileInfo[type] = label fileInfo[type] = label
console.log('[DEBUG] fileData[type] set to:', fileData[type])
console.log('[DEBUG] validationErrors[type] set to:', validationErrors[type])
console.log('[DEBUG] Button should be enabled:', !!fileData[type] && !validationErrors[type])
} }
function handleDrop(event, type) { function handleDrop(event, type) {
@ -291,11 +281,12 @@ export default {
if (tagsStr) url += `&tags=${encodeURIComponent(tagsStr)}` if (tagsStr) url += `&tags=${encodeURIComponent(tagsStr)}`
url = appendChunkParams(url) url = appendChunkParams(url)
const { default: { API_BASE } } = await import('../js/utils.js')
const res = await fetch(API_BASE + url, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: stringContent.value })
const res = await fetch(API_BASE + url, { method: 'POST', headers: { 'Content-Type': 'text/plain', ...authHeaders() }, body: stringContent.value })
const json = await res.json() const json = await res.json()
if (json.success) { if (json.success) {
results.string = `<div style="margin-top:8px;padding:12px;background:#ecfdf5;border-radius:8px;font-size:13px;">${json.message} | 分块数:<strong>${json.data.chunkCount}</strong> | 状态:<strong>${json.data.status}</strong></div>`
const isProcessing = json.data && json.data.status === 'PROCESSING'
const statusText = isProcessing ? '后台处理中...' : `分块数:<strong>${json.data.chunkCount}</strong> | 状态:<strong>已完成</strong>`
results.string = `<div style="margin-top:8px;padding:12px;background:#ecfdf5;border-radius:8px;font-size:13px;">${json.message} | ${statusText}</div>`
toast(json.message, 'success') toast(json.message, 'success')
store.loadCategories() store.loadCategories()
store.loadStats() store.loadStats()
@ -365,7 +356,9 @@ export default {
if (json.success) { if (json.success) {
successCount++ successCount++
resultsHtml.push(`<span style="color:var(--success);">${file.name}</span> — ${json.data.chunkCount || 0} 分块`)
const isProcessing = json.data && json.data.status === 'PROCESSING'
const chunkInfo = isProcessing ? '后台处理中...' : `${json.data.chunkCount || 0} 分块`
resultsHtml.push(`<span style="color:var(--success);">${file.name}</span> — ${chunkInfo}`)
} else { } else {
failCount++ failCount++
const isDuplicate = json.message && json.message.includes('重复') const isDuplicate = json.message && json.message.includes('重复')

7
src/main/resources/static/js/api.js

@ -232,12 +232,13 @@ export function deleteAccount(accountId) {
// ==================== 文档管理 ==================== // ==================== 文档管理 ====================
/** /**
* 文档列表分页 + 过滤
* 文档列表分页 + 过滤 + 关键词搜索
*/ */
export function listDocuments(page = 1, size = 10, categoryId, status) {
export function listDocuments(page = 1, size = 10, categoryId, status, keyword) {
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 (status) path += `&status=${status}` if (status) path += `&status=${status}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
return getJSON(path) return getJSON(path)
} }
@ -439,7 +440,7 @@ export function createCategory(data) {
* 更新分类 * 更新分类
*/ */
export function updateCategory(id, data) { export function updateCategory(id, data) {
return putJSON(`/category/${id}`, data)
return putJSONWithBody(`/category/${id}`, data)
} }
/** /**

Loading…
Cancel
Save