Browse Source

二期-文档列表完善批量操作、上传校验、去重、分块配置与上传进度功能

master
wanghanlin 1 day ago
parent
commit
6f861fcf79
  1. 4
      CLAUDE.md
  2. 10
      README.md
  3. 32
      src/main/java/com/wok/supportbot/config/ChunkConfig.java
  4. 19
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  5. 117
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  6. 49
      src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java
  7. 6
      src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
  8. 110
      src/main/java/com/wok/supportbot/service/DocumentService.java
  9. 86
      src/main/resources/static/components/DocList.js
  10. 165
      src/main/resources/static/components/DocUpload.js
  11. 114
      src/main/resources/static/js/api.js

4
CLAUDE.md

@ -58,6 +58,9 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **雪花 ID 精度问题**: `KnowledgeDocument.id`、`categoryId` 和 `KnowledgeCategory.id`、`parentId` 已添加 `@JsonSerialize(using = ToStringSerializer.class)`,序列化为字符串避免前端 JS 精度丢失 - **雪花 ID 精度问题**: `KnowledgeDocument.id`、`categoryId` 和 `KnowledgeCategory.id`、`parentId` 已添加 `@JsonSerialize(using = ToStringSerializer.class)`,序列化为字符串避免前端 JS 精度丢失
- PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'` - PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`
- 向量维度: 1536,距离类型: COSINE_DISTANCE,索引: HNSW - 向量维度: 1536,距离类型: COSINE_DISTANCE,索引: HNSW
- **分块配置**: `knowledge.chunk.*` 配置项(`ChunkConfig`),默认 chunkSize=200, overlap=100
- **上传校验**: `ALLOWED_EXTENSIONS` 白名单 + 50MB 大小限制,前后端双重校验
- **文档去重**: `KnowledgeDocument.contentHash` 字段(SHA-256),上传时自动计算并查重
## 前端架构 ## 前端架构
@ -74,6 +77,7 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- AI 对话: `/ai/*`(`AiController`) - AI 对话: `/ai/*`(`AiController`)
- 文档上传: `/upload/*`(`DocumentController`) - 文档上传: `/upload/*`(`DocumentController`)
- 文档管理: `/document/*`(`DocumentController`) - 文档管理: `/document/*`(`DocumentController`)
- 批量操作: `/document/batch/*`(`DocumentController`,用 POST 避免 DELETE+RequestBody 路径冲突)
- 分类管理: `/category/*`(`DocumentController`) - 分类管理: `/category/*`(`DocumentController`)
## 已知 TODO ## 已知 TODO

10
README.md

@ -19,7 +19,11 @@
- **⚡ 向量搜索**: 基于PGVector的高性能语义相似度搜索 - **⚡ 向量搜索**: 基于PGVector的高性能语义相似度搜索
- **🔧 查询优化**: 多种预检索优化策略提升问答质量 - **🔧 查询优化**: 多种预检索优化策略提升问答质量
- **📖 API文档**: 集成Knife4j提供完整的交互式API文档 - **📖 API文档**: 集成Knife4j提供完整的交互式API文档
- **📚 知识库管理**: 完整的文档生命周期管理(上传、查看、删除、重新处理)、分类管理、语义搜索测试、统计面板
- **📚 知识库管理**: 完整的文档生命周期管理、分类管理、语义搜索测试、统计面板
- **🔄 批量操作**: 批量删除、批量重新处理文档
- **🛡️ 文档去重**: 基于内容 SHA-256 哈希的自动去重
- **✅ 上传校验**: 文件类型白名单 + 大小限制 + 上传进度条
- **⚙️ 分块可配置**: 支持 application.yml 全局配置和上传时参数覆盖
## 🛠 技术栈 ## 🛠 技术栈
@ -125,6 +129,10 @@ CREATE TABLE knowledge_document (
| GET | `/document/{id}` | 文档详情 | | GET | `/document/{id}` | 文档详情 |
| GET | `/document/{id}/chunks` | 文档分块列表 | | GET | `/document/{id}/chunks` | 文档分块列表 |
| DELETE | `/document/{id}` | 删除文档(级联删除向量) | | DELETE | `/document/{id}` | 删除文档(级联删除向量) |
| POST | `/document/batch/delete` | 批量删除文档 |
| PUT | `/document/{id}` | 更新文档元信息 |
| PUT | `/document/{id}/reprocess` | 重新处理文档 |
| POST | `/document/batch/reprocess` | 批量重新处理文档 |
| PUT | `/document/{id}` | 更新文档元信息 | | PUT | `/document/{id}` | 更新文档元信息 |
| PUT | `/document/{id}/reprocess` | 重新处理文档 | | PUT | `/document/{id}/reprocess` | 重新处理文档 |
| POST | `/document/search` | 语义搜索 | | POST | `/document/search` | 语义搜索 |

32
src/main/java/com/wok/supportbot/config/ChunkConfig.java

@ -0,0 +1,32 @@
package com.wok.supportbot.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 文档分块配置
* 支持通过 application.yml 动态调整分块参数
*/
@Component
@ConfigurationProperties(prefix = "knowledge.chunk")
@EnableConfigurationProperties(ChunkConfig.class)
@Data
public class ChunkConfig {
/** 分块大小(Token 数) */
private int chunkSize = 200;
/** 分块重叠大小(Token 数) */
private int overlap = 100;
/** 最小分块字符数 */
private int minChunkSizeChars = 10;
/** 最大分块数量 */
private int maxNumChunks = 5000;
/** 是否保留分隔符 */
private boolean keepSeparator = true;
}

19
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -35,6 +35,8 @@ public class DatabaseInitConfig {
} else { } else {
// 修复已存在表的 tags 默认值从数组改为对象 // 修复已存在表的 tags 默认值从数组改为对象
fixTagsDefaultValue(); fixTagsDefaultValue();
// 自动添加 content_hash 二期新增
addContentHashColumn();
} }
log.info("数据库初始化完成"); log.info("数据库初始化完成");
@ -114,4 +116,21 @@ public class DatabaseInitConfig {
log.warn("修复 tags 默认值时出错(可能已修复)", e); log.warn("修复 tags 默认值时出错(可能已修复)", e);
} }
} }
/**
* 自动添加 content_hash 二期去重功能新增字段
*/
private void addContentHashColumn() {
try {
String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'content_hash'";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
if (count != null && count == 0) {
log.info("添加 knowledge_document.content_hash 列");
jdbcTemplate.execute("ALTER TABLE knowledge_document ADD COLUMN content_hash VARCHAR(64)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_content_hash ON knowledge_document (content_hash)");
}
} catch (Exception e) {
log.warn("添加 content_hash 列时出错", e);
}
}
} }

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

@ -13,6 +13,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* 知识库文档管理控制器 * 知识库文档管理控制器
@ -24,6 +25,41 @@ public class DocumentController {
@Autowired @Autowired
private DocumentService documentService; private DocumentService documentService;
// ==================== 上传校验常量 ====================
/** 允许上传的文件类型白名单 */
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
"txt", "md", "json", "csv", "html", "xml", "rtf"
);
/** 文件大小上限(50MB,与 application.yml 中 multipart 配置一致) */
private static final long MAX_FILE_SIZE = 50 * 1024 * 1024;
/**
* 校验上传文件
* @param file 上传的文件
*/
private void validateUploadFile(MultipartFile file) {
if (file.getSize() > MAX_FILE_SIZE) {
throw new IllegalArgumentException("文件大小超过限制(最大 50MB)");
}
String extension = getFileExtension(file.getOriginalFilename());
if (extension != null && !ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {
throw new IllegalArgumentException("不支持的文件类型: " + extension);
}
}
/**
* 获取文件扩展名
*/
private String getFileExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return null;
}
return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
}
// ==================== 文档上传 ==================== // ==================== 文档上传 ====================
/** /**
@ -42,6 +78,7 @@ public class DocumentController {
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) List<String> tags) { @RequestParam(required = false) List<String> tags) {
try { try {
validateUploadFile(file);
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,
@ -96,6 +133,7 @@ public class DocumentController {
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) List<String> tags) { @RequestParam(required = false) List<String> tags) {
try { try {
validateUploadFile(file);
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,
@ -120,6 +158,7 @@ public class DocumentController {
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) List<String> tags) { @RequestParam(required = false) List<String> tags) {
try { try {
validateUploadFile(file);
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,
@ -145,6 +184,7 @@ public class DocumentController {
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) List<String> tags) { @RequestParam(required = false) List<String> tags) {
try { try {
validateUploadFile(file);
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,
@ -171,6 +211,7 @@ public class DocumentController {
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) List<String> tags) { @RequestParam(required = false) List<String> tags) {
try { try {
validateUploadFile(file);
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,
@ -286,6 +327,82 @@ public class DocumentController {
} }
} }
/**
* 批量删除文档
*/
@PostMapping("/document/batch/delete")
public ResponseEntity<Map<String, Object>> batchDeleteDocuments(@RequestBody Map<String, Object> body) {
try {
List<Long> ids = extractIds(body);
if (ids.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "请提供要删除的文档ID列表"
));
}
Map<String, Object> result = documentService.batchDeleteDocuments(ids);
return ResponseEntity.ok(Map.of(
"success", true,
"message", String.format("批量删除完成:成功 %d 个,失败 %d 个",
result.get("successCount"), result.get("failCount")),
"data", result
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "批量删除失败:" + e.getMessage()
));
}
}
/**
* 批量重新处理文档
*/
@PostMapping("/document/batch/reprocess")
public ResponseEntity<Map<String, Object>> batchReprocessDocuments(@RequestBody Map<String, Object> body) {
try {
List<Long> ids = extractIds(body);
if (ids.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "请提供要重新处理的文档ID列表"
));
}
Map<String, Object> result = documentService.batchReprocessDocuments(ids);
return ResponseEntity.ok(Map.of(
"success", true,
"message", String.format("批量重新处理完成:成功 %d 个,失败 %d 个",
result.get("successCount"), result.get("failCount")),
"data", result
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "批量重新处理失败:" + e.getMessage()
));
}
}
/**
* 从请求体中提取 ID 列表兼容字符串和数字类型的 ID
*/
@SuppressWarnings("unchecked")
private List<Long> extractIds(Map<String, Object> body) {
List<?> rawIds = (List<?>) body.get("ids");
if (rawIds == null || rawIds.isEmpty()) {
return List.of();
}
return rawIds.stream().map(id -> {
if (id instanceof Number) {
return ((Number) id).longValue();
} else if (id instanceof String) {
return Long.parseLong((String) id);
} else {
throw new IllegalArgumentException("无效的ID格式: " + id);
}
}).toList();
}
/** /**
* 更新文档元信息 * 更新文档元信息
*/ */

49
src/main/java/com/wok/supportbot/document/transform/MyTokenTextSplitter.java

@ -1,34 +1,67 @@
package com.wok.supportbot.document.transform; package com.wok.supportbot.document.transform;
import com.wok.supportbot.config.ChunkConfig;
import org.springframework.ai.document.Document; import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter; import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List; import java.util.List;
/** /**
* 自定义基于 Token 的切词器 * 自定义基于 Token 的切词器
* 支持通过 ChunkConfig 动态调整分块参数
*/ */
@Component @Component
public class MyTokenTextSplitter { public class MyTokenTextSplitter {
@Autowired
private ChunkConfig chunkConfig;
/** /**
* 使用默认设置创建分割器
* @param documents
* @return
* 使用全局配置参数创建分割器
*/ */
public List<Document> splitDocuments(List<Document> documents) { public List<Document> splitDocuments(List<Document> documents) {
TokenTextSplitter splitter = new TokenTextSplitter();
TokenTextSplitter splitter = new TokenTextSplitter(
chunkConfig.getChunkSize(),
chunkConfig.getOverlap(),
chunkConfig.getMinChunkSizeChars(),
chunkConfig.getMaxNumChunks(),
chunkConfig.isKeepSeparator()
);
return splitter.apply(documents);
}
/**
* 使用自定义参数创建分割器覆盖全局配置
*
* @param documents 文档列表
* @param chunkSize 分块大小
* @param overlap 重叠大小
*/
public List<Document> splitDocuments(List<Document> documents, Integer chunkSize, Integer overlap) {
int cs = chunkSize != null ? chunkSize : chunkConfig.getChunkSize();
int ol = overlap != null ? overlap : chunkConfig.getOverlap();
TokenTextSplitter splitter = new TokenTextSplitter(
cs, ol,
chunkConfig.getMinChunkSizeChars(),
chunkConfig.getMaxNumChunks(),
chunkConfig.isKeepSeparator()
);
return splitter.apply(documents); return splitter.apply(documents);
} }
/** /**
* 使用自定义参数创建分割器通过调整参数可以控制分割的粒度和方式适应不同的应用场景
* @param documents
* @return
* 使用自定义参数创建分割器全参数覆盖
*/ */
public List<Document> splitCustomized(List<Document> documents) { public List<Document> splitCustomized(List<Document> documents) {
TokenTextSplitter splitter = new TokenTextSplitter(200, 100, 10, 5000, true);
TokenTextSplitter splitter = new TokenTextSplitter(
chunkConfig.getChunkSize(),
chunkConfig.getOverlap(),
chunkConfig.getMinChunkSizeChars(),
chunkConfig.getMaxNumChunks(),
chunkConfig.isKeepSeparator()
);
return splitter.apply(documents); return splitter.apply(documents);
} }
} }

6
src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java

@ -93,6 +93,12 @@ public class KnowledgeDocument implements Serializable {
@TableField("error_message") @TableField("error_message")
private String errorMessage; private String errorMessage;
/**
* 内容哈希值SHA-256用于文档去重
*/
@TableField("content_hash")
private String contentHash;
/** /**
* 创建时间 * 创建时间
*/ */

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

@ -24,6 +24,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -84,6 +87,15 @@ public class DocumentService {
public KnowledgeDocument uploadDocument(List<Document> documents, String title, String sourceName, public KnowledgeDocument uploadDocument(List<Document> documents, String title, String sourceName,
String fileType, Long fileSize, String content, String fileType, Long fileSize, String content,
Long categoryId, List<String> tags) { Long categoryId, List<String> tags) {
// 0. 内容去重检查
String contentHash = computeContentHash(content);
if (contentHash != null) {
String duplicateTitle = checkContentDuplicate(contentHash);
if (duplicateTitle != null) {
throw new RuntimeException("文档内容重复,已有同名文档: " + duplicateTitle);
}
}
// 1. 创建文档记录状态 PROCESSING // 1. 创建文档记录状态 PROCESSING
KnowledgeDocument docRecord = KnowledgeDocument.builder() KnowledgeDocument docRecord = KnowledgeDocument.builder()
.title(title != null ? title : sourceName) .title(title != null ? title : sourceName)
@ -93,6 +105,7 @@ public class DocumentService {
.content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content) .content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content)
.categoryId(categoryId != null ? categoryId : 0L) .categoryId(categoryId != null ? categoryId : 0L)
.tags(tags != null ? Map.of("tags", tags) : null) .tags(tags != null ? Map.of("tags", tags) : null)
.contentHash(contentHash)
.status("PROCESSING") .status("PROCESSING")
.chunkCount(0) .chunkCount(0)
.build(); .build();
@ -305,6 +318,66 @@ public class DocumentService {
return vectorCount; return vectorCount;
} }
/**
* 批量删除文档
*
* @param ids 文档ID列表
* @return 批量操作结果
*/
public Map<String, Object> batchDeleteDocuments(List<Long> ids) {
int successCount = 0;
int failCount = 0;
List<Map<String, Object>> details = new ArrayList<>();
for (Long id : ids) {
try {
int vectorCount = deleteDocument(id);
successCount++;
details.add(Map.of("id", id, "success", true, "deletedVectors", vectorCount));
} catch (Exception e) {
failCount++;
details.add(Map.of("id", id, "success", false, "message", e.getMessage()));
log.warn("批量删除文档失败: id={}", id, e);
}
}
Map<String, Object> result = new HashMap<>();
result.put("successCount", successCount);
result.put("failCount", failCount);
result.put("details", details);
return result;
}
/**
* 批量重新处理文档
*
* @param ids 文档ID列表
* @return 批量操作结果
*/
public Map<String, Object> batchReprocessDocuments(List<Long> ids) {
int successCount = 0;
int failCount = 0;
List<Map<String, Object>> details = new ArrayList<>();
for (Long id : ids) {
try {
reprocessDocument(id);
successCount++;
details.add(Map.of("id", id, "success", true));
} catch (Exception e) {
failCount++;
details.add(Map.of("id", id, "success", false, "message", e.getMessage()));
log.warn("批量重新处理文档失败: id={}", id, e);
}
}
Map<String, Object> result = new HashMap<>();
result.put("successCount", successCount);
result.put("failCount", failCount);
result.put("details", details);
return result;
}
/** /**
* 重新处理文档重新分块 + 向量化 * 重新处理文档重新分块 + 向量化
*/ */
@ -580,6 +653,43 @@ public class DocumentService {
// ==================== 内部方法 ==================== // ==================== 内部方法 ====================
/**
* 计算文本内容的 SHA-256 哈希值
*/
private String computeContentHash(String content) {
if (content == null || content.isEmpty()) {
return null;
}
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(content.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
log.error("SHA-256 算法不可用", e);
return null;
}
}
/**
* 检查内容是否重复
* @param contentHash 内容哈希值
* @return 重复文档的标题如果不存在重复则返回 null
*/
private String checkContentDuplicate(String contentHash) {
if (contentHash == null) {
return null;
}
QueryWrapper<KnowledgeDocument> wrapper = new QueryWrapper<>();
wrapper.eq("content_hash", contentHash);
wrapper.select("title");
KnowledgeDocument existing = documentMapper.selectOne(wrapper);
return existing != null ? existing.getTitle() : null;
}
/** /**
* 根据文档ID删除 vector_store 中关联的所有向量 * 根据文档ID删除 vector_store 中关联的所有向量
*/ */

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

@ -1,9 +1,9 @@
/** /**
* 📋 文档列表 + 分页
* 📋 文档列表 + 分页 + 批量操作
*/ */
import { ref } from 'vue' import { ref } from 'vue'
import { store } from '../js/store.js' import { store } from '../js/store.js'
import { listDocuments, deleteDocument, reprocessDocument } from '../js/api.js'
import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments } from '../js/api.js'
import { toast, formatDate } from '../js/utils.js' import { toast, formatDate } from '../js/utils.js'
export default { export default {
@ -22,12 +22,21 @@ export default {
<option value="FAILED"> 失败</option> <option value="FAILED"> 失败</option>
</select> </select>
<button class="btn btn-outline btn-sm" @click="load()">🔄 刷新</button> <button class="btn btn-outline btn-sm" @click="load()">🔄 刷新</button>
<!-- 批量操作按钮 -->
<template v-if="selectedIds.size > 0">
<span style="font-size:12px;color:var(--primary);font-weight:600;">已选 {{ selectedIds.size }} </span>
<button class="btn btn-danger btn-sm" @click="batchRemove">🗑 批量删除</button>
<button class="btn btn-warn btn-sm" @click="batchReprocess">🔄 批量重新处理</button>
<button class="btn btn-outline btn-sm" @click="clearSelection">取消选择</button>
</template>
</div> </div>
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th><input type="checkbox" :checked="isAllSelected" @change="toggleSelectAll" style="cursor:pointer;"></th>
<th>ID</th> <th>ID</th>
<th>标题</th> <th>标题</th>
<th>类型</th> <th>类型</th>
@ -39,9 +48,10 @@ export default {
</thead> </thead>
<tbody> <tbody>
<tr v-if="documents.length === 0"> <tr v-if="documents.length === 0">
<td colspan="7" style="text-align:center;color:var(--sub);">暂无文档</td>
<td colspan="8" style="text-align:center;color:var(--sub);">暂无文档</td>
</tr> </tr>
<tr v-for="d in documents" :key="d.id">
<tr v-for="d in documents" :key="d.id" :style="selectedIds.has(d.id) ? 'background:#eef2ff;' : ''">
<td><input type="checkbox" :checked="selectedIds.has(d.id)" @change="toggleSelect(d.id)" style="cursor:pointer;"></td>
<td>{{ d.id }}</td> <td>{{ d.id }}</td>
<td> <td>
<strong>{{ d.title }}</strong><br> <strong>{{ d.title }}</strong><br>
@ -79,6 +89,12 @@ export default {
const total = ref(0) const total = ref(0)
const filterCategory = ref('') const filterCategory = ref('')
const filterStatus = ref('') const filterStatus = ref('')
const selectedIds = ref(new Set())
// 计算是否全选
const isAllSelected = () => {
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'
@ -88,6 +104,8 @@ export default {
async function load(p = 1) { async function load(p = 1) {
page.value = p page.value = p
// 切换页面时清空选择
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)
if (json.success) { if (json.success) {
@ -102,6 +120,27 @@ export default {
} }
} }
function toggleSelect(id) {
const s = new Set(selectedIds.value)
if (s.has(id)) s.delete(id)
else s.add(id)
selectedIds.value = s
}
function toggleSelectAll() {
if (isAllSelected()) {
selectedIds.value = new Set()
} else {
const s = new Set()
documents.value.forEach(d => s.add(d.id))
selectedIds.value = s
}
}
function clearSelection() {
selectedIds.value = new Set()
}
function viewDetail(id) { function viewDetail(id) {
store.openDetail(id) store.openDetail(id)
} }
@ -137,9 +176,46 @@ export default {
} }
} }
async function batchRemove() {
const ids = Array.from(selectedIds.value)
if (!confirm(`确定删除选中的 ${ids.length} 个文档?关联的向量也将被删除`)) return
try {
const json = await batchDeleteDocuments(ids)
if (json.success) {
toast(json.message, 'success')
selectedIds.value = new Set()
load(page.value)
store.loadStats()
} else {
toast(json.message || '批量删除失败', 'error')
}
} catch (e) {
toast('批量删除失败:' + e.message, 'error')
}
}
async function batchReprocess() {
const ids = Array.from(selectedIds.value)
if (!confirm(`确定重新处理选中的 ${ids.length} 个文档?`)) return
try {
const json = await batchReprocessDocuments(ids)
if (json.success) {
toast(json.message, 'success')
selectedIds.value = new Set()
load(page.value)
} else {
toast(json.message || '批量重新处理失败', 'error')
}
} catch (e) {
toast('批量重新处理失败:' + e.message, 'error')
}
}
return { return {
documents, page, pages, total, filterCategory, filterStatus, documents, page, pages, total, filterCategory, filterStatus,
store, statusClass, load, viewDetail, remove, reprocess, formatDate
selectedIds, store, statusClass, isAllSelected,
load, toggleSelect, toggleSelectAll, clearSelection,
viewDetail, remove, reprocess, batchRemove, batchReprocess, formatDate
} }
} }
} }

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

@ -1,12 +1,23 @@
/** /**
* 📤 文档上传面板 * 📤 文档上传面板
* 支持 6 种上传格式通用文件文本MarkdownJSON3种模式
* 支持 6 种上传格式 + 前端校验 + 上传进度 + 分块配置
*/ */
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 } from '../js/utils.js'
// ==================== 上传校验常量 ====================
/** 允许上传的文件类型白名单 */
const ALLOWED_EXTENSIONS = new Set([
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
'txt', 'md', 'json', 'csv', 'html', 'xml', 'rtf'
])
/** 文件大小上限 50MB */
const MAX_FILE_SIZE = 50 * 1024 * 1024
export default { export default {
template: ` template: `
<div class="card"> <div class="card">
@ -22,6 +33,21 @@ export default {
<input class="input" v-model="uploadTags" placeholder="标签,逗号分隔(可选)"> <input class="input" v-model="uploadTags" placeholder="标签,逗号分隔(可选)">
</div> </div>
<!-- 高级设置折叠面板 -->
<div style="margin-bottom:16px;">
<button class="btn btn-outline btn-sm" @click="showAdvanced = !showAdvanced">
{{ showAdvanced ? '▼ 隐藏高级设置' : '▶ 高级设置(分块参数)' }}
</button>
<div v-show="showAdvanced" style="margin-top:8px;padding:12px;background:#f9fafb;border-radius:8px;border:1px solid var(--border);">
<p style="font-size:12px;color:var(--sub);margin-bottom:8px;">留空则使用全局配置分块大小 200 Token重叠 100 Token</p>
<div class="input-row" style="margin-bottom:0;">
<input class="input input-sm" v-model.number="chunkSizeOverride" placeholder="分块大小" type="number" min="50" max="2000">
<input class="input input-sm" v-model.number="overlapOverride" placeholder="重叠大小" type="number" min="0" max="500">
<span style="font-size:12px;color:var(--sub);line-height:38px;">Token</span>
</div>
</div>
</div>
<!-- Tab --> <!-- Tab -->
<div style="display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;border-bottom:1px solid var(--border);padding-bottom:8px;"> <div style="display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;border-bottom:1px solid var(--border);padding-bottom:8px;">
<button v-for="t in subTabs" :key="t.key" <button v-for="t in subTabs" :key="t.key"
@ -29,15 +55,24 @@ export default {
@click="activeSubTab = t.key">{{ t.icon }} {{ t.label }}</button> @click="activeSubTab = t.key">{{ t.icon }} {{ t.label }}</button>
</div> </div>
<!-- 上传进度条 -->
<div v-if="uploadProgress >= 0" style="margin-bottom:12px;">
<div style="background:#e5e7eb;border-radius:6px;height:8px;overflow:hidden;">
<div :style="'width:' + uploadProgress + '%;height:100%;background:var(--primary);transition:width .3s;border-radius:6px;'"></div>
</div>
<div style="font-size:12px;color:var(--sub);margin-top:4px;">上传进度{{ uploadProgress }}%</div>
</div>
<!-- 通用文件 --> <!-- 通用文件 -->
<div v-show="activeSubTab === 'file'"> <div v-show="activeSubTab === 'file'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/file</code><span style="font-size:12px;color:var(--sub);">Tika </span></div> <div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/file</code><span style="font-size:12px;color:var(--sub);">Tika </span></div>
<div class="upload-zone" @click="$refs.fileInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'file')"> <div class="upload-zone" @click="$refs.fileInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'file')">
<div class="icon">📎</div><p>点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT </p> <div class="icon">📎</div><p>点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT </p>
<input type="file" ref="fileInput" style="display:none" multiple @change="handleFileSelect($event, 'file')">
<input type="file" ref="fileInput" style="display:none" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.html,.xml,.rtf" @change="handleFileSelect($event, 'file')">
</div> </div>
<div v-html="fileInfo.file" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> <div v-html="fileInfo.file" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<button class="btn btn-primary" @click="doUpload('file')" :disabled="!fileData.file">🚀 上传并向量化</button>
<div v-if="validationErrors.file" style="margin-bottom:8px;font-size:13px;color:var(--danger);"> {{ validationErrors.file }}</div>
<button class="btn btn-primary" @click="doUpload('file')" :disabled="!fileData.file || validationErrors.file">🚀 上传并向量化</button>
<div v-html="results.file"></div> <div v-html="results.file"></div>
</div> </div>
@ -59,7 +94,8 @@ export default {
<input type="file" ref="mdInput" style="display:none" accept=".md" multiple @change="handleFileSelect($event, 'markdown')"> <input type="file" ref="mdInput" style="display:none" accept=".md" multiple @change="handleFileSelect($event, 'markdown')">
</div> </div>
<div v-html="fileInfo.markdown" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> <div v-html="fileInfo.markdown" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<button class="btn btn-primary" @click="doUpload('markdown')" :disabled="!fileData.markdown">🚀 上传并向量化</button>
<div v-if="validationErrors.markdown" style="margin-bottom:8px;font-size:13px;color:var(--danger);"> {{ validationErrors.markdown }}</div>
<button class="btn btn-primary" @click="doUpload('markdown')" :disabled="!fileData.markdown || validationErrors.markdown">🚀 上传并向量化</button>
<div v-html="results.markdown"></div> <div v-html="results.markdown"></div>
</div> </div>
@ -71,7 +107,8 @@ export default {
<input type="file" ref="jsonBInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonBasic')"> <input type="file" ref="jsonBInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonBasic')">
</div> </div>
<div v-html="fileInfo.jsonBasic" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> <div v-html="fileInfo.jsonBasic" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<button class="btn btn-primary" @click="doUpload('jsonBasic')" :disabled="!fileData.jsonBasic">🚀 上传并向量化</button>
<div v-if="validationErrors.jsonBasic" style="margin-bottom:8px;font-size:13px;color:var(--danger);"> {{ validationErrors.jsonBasic }}</div>
<button class="btn btn-primary" @click="doUpload('jsonBasic')" :disabled="!fileData.jsonBasic || validationErrors.jsonBasic">🚀 上传并向量化</button>
<div v-html="results.jsonBasic"></div> <div v-html="results.jsonBasic"></div>
</div> </div>
@ -83,8 +120,9 @@ export default {
<input type="file" ref="jsonFInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonFields')"> <input type="file" ref="jsonFInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonFields')">
</div> </div>
<div v-html="fileInfo.jsonFields" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> <div v-html="fileInfo.jsonFields" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<div v-if="validationErrors.jsonFields" style="margin-bottom:8px;font-size:13px;color:var(--danger);"> {{ validationErrors.jsonFields }}</div>
<input class="input" v-model="jsonFieldsStr" placeholder="输入要提取的字段名(逗号分隔),如:title,description,content" style="margin-bottom:12px;"> <input class="input" v-model="jsonFieldsStr" placeholder="输入要提取的字段名(逗号分隔),如:title,description,content" style="margin-bottom:12px;">
<button class="btn btn-primary" @click="doUpload('jsonFields')" :disabled="!fileData.jsonFields">🚀 上传并向量化</button>
<button class="btn btn-primary" @click="doUpload('jsonFields')" :disabled="!fileData.jsonFields || validationErrors.jsonFields">🚀 上传并向量化</button>
<div v-html="results.jsonFields"></div> <div v-html="results.jsonFields"></div>
</div> </div>
@ -96,8 +134,9 @@ export default {
<input type="file" ref="jsonPInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonPointer')"> <input type="file" ref="jsonPInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonPointer')">
</div> </div>
<div v-html="fileInfo.jsonPointer" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div> <div v-html="fileInfo.jsonPointer" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<div v-if="validationErrors.jsonPointer" style="margin-bottom:8px;font-size:13px;color:var(--danger);"> {{ validationErrors.jsonPointer }}</div>
<input class="input" v-model="jsonPointerStr" placeholder="输入 JSON Pointer 路径,如:/data/items 或 /products" style="margin-bottom:12px;"> <input class="input" v-model="jsonPointerStr" placeholder="输入 JSON Pointer 路径,如:/data/items 或 /products" style="margin-bottom:12px;">
<button class="btn btn-primary" @click="doUpload('jsonPointer')" :disabled="!fileData.jsonPointer">🚀 上传并向量化</button>
<button class="btn btn-primary" @click="doUpload('jsonPointer')" :disabled="!fileData.jsonPointer || validationErrors.jsonPointer">🚀 上传并向量化</button>
<div v-html="results.jsonPointer"></div> <div v-html="results.jsonPointer"></div>
</div> </div>
</div> </div>
@ -110,6 +149,10 @@ export default {
const stringContent = ref('') const stringContent = ref('')
const jsonFieldsStr = ref('') const jsonFieldsStr = ref('')
const jsonPointerStr = ref('') const jsonPointerStr = ref('')
const showAdvanced = ref(false)
const chunkSizeOverride = ref(null)
const overlapOverride = ref(null)
const uploadProgress = ref(-1)
const fileData = reactive({ const fileData = reactive({
file: null, markdown: null, jsonBasic: null, jsonFields: null, jsonPointer: null file: null, markdown: null, jsonBasic: null, jsonFields: null, jsonPointer: null
@ -123,6 +166,10 @@ export default {
file: '', string: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: '' file: '', string: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: ''
}) })
const validationErrors = reactive({
file: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: ''
})
const subTabs = [ const subTabs = [
{ key: 'file', icon: '📎', label: '通用文件' }, { key: 'file', icon: '📎', label: '通用文件' },
{ key: 'string', icon: '📝', label: '文本内容' }, { key: 'string', icon: '📝', label: '文本内容' },
@ -132,31 +179,81 @@ export default {
{ key: 'jsonPointer', icon: '📍', label: 'JSON 按指针' } { key: 'jsonPointer', icon: '📍', label: 'JSON 按指针' }
] ]
/**
* 校验文件列表返回错误信息或空字符串
*/
function validateFiles(files) {
for (const f of files) {
// 文件大小校验
if (f.size > MAX_FILE_SIZE) {
return `文件"${f.name}"超过 50MB 大小限制`
}
// 文件类型校验
const ext = f.name.includes('.') ? f.name.substring(f.name.lastIndexOf('.') + 1).toLowerCase() : ''
if (ext && !ALLOWED_EXTENSIONS.has(ext)) {
return `文件类型".${ext}"不在允许列表中`
}
}
return ''
}
function handleFileSelect(event, type) { function handleFileSelect(event, type) {
const input = event.target const input = event.target
if (!input.files || input.files.length === 0) return if (!input.files || input.files.length === 0) return
fileData[type] = Array.from(input.files)
const totalSize = fileData[type].reduce((s, f) => s + f.size, 0)
const label = fileData[type].length > 1
? `已选择 <strong>${fileData[type].length}</strong> 个文件(共 ${formatBytes(totalSize)}`
: `已选择:<strong>${fileData[type][0].name}</strong> (${formatBytes(fileData[type][0].size)})`
const files = Array.from(input.files)
// 前端校验
const error = validateFiles(files)
if (error) {
// 校验不通过:清空数据,保持按钮禁用
validationErrors[type] = error
fileData[type] = null
fileInfo[type] = `<span style="color:var(--danger);">${error}</span>`
toast(error, 'error')
return
}
// 校验通过
validationErrors[type] = ''
fileData[type] = files
const totalSize = files.reduce((s, f) => s + f.size, 0)
const label = files.length > 1
? `已选择 <strong>${files.length}</strong> 个文件(共 ${formatBytes(totalSize)}`
: `已选择:<strong>${files[0].name}</strong> (${formatBytes(files[0].size)})`
fileInfo[type] = label fileInfo[type] = label
} }
function handleDrop(event, type) { function handleDrop(event, type) {
event.currentTarget.classList.remove('drag-over') event.currentTarget.classList.remove('drag-over')
const refMap = { file: 'fileInput', markdown: 'mdInput', jsonBasic: 'jsonBInput', jsonFields: 'jsonFInput', jsonPointer: 'jsonPInput' }
// 模拟文件选择
const dt = new DataTransfer() const dt = new DataTransfer()
for (const f of event.dataTransfer.files) dt.items.add(f) for (const f of event.dataTransfer.files) dt.items.add(f)
// 通过 handleFileSelect 处理
const fakeEvent = { target: { files: dt.files } } const fakeEvent = { target: { files: dt.files } }
handleFileSelect(fakeEvent, type) handleFileSelect(fakeEvent, type)
} }
/**
* 构建分块参数的 query string
*/
function chunkParams() {
const params = []
if (chunkSizeOverride.value) params.push(`chunkSize=${chunkSizeOverride.value}`)
if (overlapOverride.value) params.push(`overlap=${overlapOverride.value}`)
return params.length > 0 ? (params.join('&')) : ''
}
/**
* 将分块参数附加到 URL
*/
function appendChunkParams(url) {
const cp = chunkParams()
if (!cp) return url
return url + (url.includes('?') ? '&' : '?') + cp
}
async function doUpload(type) { async function doUpload(type) {
const catId = uploadCategory.value const catId = uploadCategory.value
const tagsStr = uploadTags.value.trim() const tagsStr = uploadTags.value.trim()
uploadProgress.value = -1
// 文本内容上传 // 文本内容上传
if (type === 'string') { if (type === 'string') {
@ -166,14 +263,23 @@ export default {
} }
try { try {
const title = stringTitle.value.trim() || stringContent.value.trim().substring(0, 30) const title = stringTitle.value.trim() || stringContent.value.trim().substring(0, 30)
const json = await uploadString(stringContent.value, title, catId || undefined, tagsStr || undefined)
let url = `/upload/string?title=${encodeURIComponent(title)}`
if (catId) url += `&categoryId=${catId}`
if (tagsStr) url += `&tags=${encodeURIComponent(tagsStr)}`
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 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>` 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>`
toast(json.message, 'success') toast(json.message, 'success')
store.loadCategories() store.loadCategories()
store.loadStats() store.loadStats()
} else { } else {
results.string = `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">${json.message}</div>`
const isDuplicate = json.message && json.message.includes('重复')
results.string = `<div style="margin-top:8px;padding:12px;background:${isDuplicate ? '#fef3c7' : '#fef2f2'};border-radius:8px;font-size:13px;color:${isDuplicate ? '#b45309' : 'var(--danger)'};">${json.message}</div>`
if (!isDuplicate) toast(json.message, 'error')
} }
} catch (e) { } catch (e) {
results.string = `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">上传失败:${e.message}</div>` results.string = `<div style="margin-top:8px;padding:12px;background:#fef2f2;border-radius:8px;font-size:13px;color:var(--danger);">上传失败:${e.message}</div>`
@ -194,6 +300,7 @@ export default {
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i] const file = files[i]
uploadProgress.value = files.length > 1 ? Math.round((i / files.length) * 100) : 0
try { try {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
@ -201,30 +308,34 @@ export default {
if (tagsStr) formData.append('tags', tagsStr) if (tagsStr) formData.append('tags', tagsStr)
let json let json
const onProgress = files.length === 1 ? (p) => { uploadProgress.value = p } : null
switch (type) { switch (type) {
case 'file': case 'file':
json = await uploadFile(formData)
json = await uploadFile(formData, onProgress)
break break
case 'markdown': case 'markdown':
json = await uploadMarkdown(formData)
json = await uploadMarkdown(formData, onProgress)
break break
case 'jsonBasic': case 'jsonBasic':
json = await uploadJsonBasic(formData)
json = await uploadJsonBasic(formData, onProgress)
break break
case 'jsonFields': { case 'jsonFields': {
if (!jsonFieldsStr.value.trim()) { if (!jsonFieldsStr.value.trim()) {
toast('请输入要提取的字段名', 'error') toast('请输入要提取的字段名', 'error')
uploadProgress.value = -1
return return
} }
json = await uploadJsonFields(formData, jsonFieldsStr.value.trim())
json = await uploadJsonFields(formData, jsonFieldsStr.value.trim(), onProgress)
break break
} }
case 'jsonPointer': { case 'jsonPointer': {
if (!jsonPointerStr.value.trim()) { if (!jsonPointerStr.value.trim()) {
toast('请输入 JSON Pointer 路径', 'error') toast('请输入 JSON Pointer 路径', 'error')
uploadProgress.value = -1
return return
} }
json = await uploadJsonPointer(formData, jsonPointerStr.value.trim())
json = await uploadJsonPointer(formData, jsonPointerStr.value.trim(), onProgress)
break break
} }
} }
@ -234,7 +345,8 @@ export default {
resultsHtml.push(`<span style="color:var(--success);">${file.name}</span> — ${json.data.chunkCount || 0} 分块`) resultsHtml.push(`<span style="color:var(--success);">${file.name}</span> — ${json.data.chunkCount || 0} 分块`)
} else { } else {
failCount++ failCount++
resultsHtml.push(`<span style="color:var(--danger);">${file.name}</span> — ${json.message || '错误'}`)
const isDuplicate = json.message && json.message.includes('重复')
resultsHtml.push(`<span style="color:${isDuplicate ? '#b45309' : 'var(--danger)'};">${file.name}</span> — ${json.message || '错误'}`)
} }
} catch (e) { } catch (e) {
failCount++ failCount++
@ -242,6 +354,9 @@ export default {
} }
} }
uploadProgress.value = 100
setTimeout(() => { uploadProgress.value = -1 }, 1500)
const summary = successCount > 0 const summary = successCount > 0
? `上传完成:成功 ${successCount}${failCount > 0 ? `,失败 ${failCount}` : ''}` ? `上传完成:成功 ${successCount}${failCount > 0 ? `,失败 ${failCount}` : ''}`
: `全部失败(${failCount} 个文件)` : `全部失败(${failCount} 个文件)`
@ -257,7 +372,9 @@ export default {
return { return {
activeSubTab, uploadCategory, uploadTags, subTabs, activeSubTab, uploadCategory, uploadTags, subTabs,
stringTitle, stringContent, jsonFieldsStr, jsonPointerStr, stringTitle, stringContent, jsonFieldsStr, jsonPointerStr,
fileData, fileInfo, results, store,
showAdvanced, chunkSizeOverride, overlapOverride,
uploadProgress,
fileData, fileInfo, results, validationErrors, store,
handleFileSelect, handleDrop, doUpload, formatBytes handleFileSelect, handleDrop, doUpload, formatBytes
} }
} }

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

@ -27,7 +27,31 @@ async function postJSON(path, body) {
} }
/** /**
* DELETE 请求返回 JSON
* DELETE 请求 + JSON body返回 JSON用于批量删除等场景
*/
async function deleteJSONWithBody(path, body) {
const res = await fetch(API_BASE + path, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
return res.json()
}
/**
* PUT 请求 + JSON body返回 JSON用于批量操作等场景
*/
async function putJSONWithBody(path, body) {
const res = await fetch(API_BASE + path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
return res.json()
}
/**
* DELETE 请求返回 JSON body
*/ */
async function deleteJSON(path) { async function deleteJSON(path) {
const res = await fetch(API_BASE + path, { method: 'DELETE' }) const res = await fetch(API_BASE + path, { method: 'DELETE' })
@ -35,7 +59,7 @@ async function deleteJSON(path) {
} }
/** /**
* PUT 请求返回 JSON
* PUT 请求返回 JSON参数走 query string
*/ */
async function putJSON(path, params) { async function putJSON(path, params) {
let url = API_BASE + path let url = API_BASE + path
@ -150,7 +174,21 @@ export function reprocessDocument(id) {
return putJSON(`/document/${id}/reprocess`) return putJSON(`/document/${id}/reprocess`)
} }
// ==================== 语义搜索 ====================
/**
* 批量删除文档
*/
export function batchDeleteDocuments(ids) {
return postJSON('/document/batch/delete', { ids })
}
/**
* 批量重新处理文档
*/
export function batchReprocessDocuments(ids) {
return postJSON('/document/batch/reprocess', { ids })
}
/**
/** /**
* 语义搜索 * 语义搜索
@ -173,9 +211,51 @@ export function getStats() {
// ==================== 上传 ==================== // ==================== 上传 ====================
/** /**
* 上传普通文件
* 上传文件带进度回调
* 使用 XMLHttpRequest 替代 fetch支持监听上传进度
*
* @param {string} path 请求路径
* @param {FormData} formData 表单数据
* @param {function(number): void} onProgress 进度回调参数为 0-100 的百分比
* @returns {Promise<object>}
*/
function postFormWithProgress(path, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', API_BASE + path)
// 上传进度监听
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100))
}
})
xhr.addEventListener('load', () => {
try {
resolve(JSON.parse(xhr.responseText))
} catch (e) {
reject(new Error('响应解析失败'))
}
})
xhr.addEventListener('error', () => reject(new Error('网络错误')))
xhr.addEventListener('abort', () => reject(new Error('上传已取消')))
xhr.send(formData)
})
}
/**
* 上传普通文件带进度
*/
export function uploadFile(formData, onProgress) {
return postFormWithProgress('/upload/file', formData, onProgress)
}
/**
* 上传普通文件无进度兼容旧调用
*/ */
export function uploadFile(formData) {
export function uploadFileSimple(formData) {
return postForm('/upload/file', formData) return postForm('/upload/file', formData)
} }
@ -194,31 +274,31 @@ export function uploadString(content, title, categoryId, tags) {
} }
/** /**
* 上传 Markdown
* 上传 Markdown带进度
*/ */
export function uploadMarkdown(formData) {
return postForm('/upload/markdown', formData)
export function uploadMarkdown(formData, onProgress) {
return postFormWithProgress('/upload/markdown', formData, onProgress)
} }
/** /**
* 上传 JSON基本方式
* 上传 JSON基本方式带进度
*/ */
export function uploadJsonBasic(formData) {
return postForm('/upload/json/basic', formData)
export function uploadJsonBasic(formData, onProgress) {
return postFormWithProgress('/upload/json/basic', formData, onProgress)
} }
/** /**
* 上传 JSON按字段提取
* 上传 JSON按字段提取带进度
*/ */
export function uploadJsonFields(formData, fields) {
return postForm(`/upload/json/fields?fields=${encodeURIComponent(fields)}`, formData)
export function uploadJsonFields(formData, fields, onProgress) {
return postFormWithProgress(`/upload/json/fields?fields=${encodeURIComponent(fields)}`, formData, onProgress)
} }
/** /**
* 上传 JSON按指针拆分
* 上传 JSON按指针拆分带进度
*/ */
export function uploadJsonPointer(formData, pointer) {
return postForm(`/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`, formData)
export function uploadJsonPointer(formData, pointer, onProgress) {
return postFormWithProgress(`/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`, formData, onProgress)
} }
// ==================== 分类 ==================== // ==================== 分类 ====================

Loading…
Cancel
Save