From 30e1145ee7fe14f6d61fd64c4f64825c976e4c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Tue, 3 Mar 2026 17:18:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81FAQ=20Excel=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=AF=BC=E5=87=BA=E5=B9=B6=E4=BC=98=E5=8C=96=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=AB=AF=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 FAQ Excel 导入、导出、模板下载接口及导入结果 VO,支持按分类路径+问题 upsert 与逐行容错 - 模板增加填写说明与更宽列宽,导出列与模板保持一致 - 管理端新增导入弹窗与结果展示,FAQ 列表操作栏精简为"添加 + 更多操作"并去除多余外层框 - 修复导出前校验顺序,避免非 FAQ 知识库触发默认分类写入 --- .../controller/ai/FaqItemController.java | 45 ++ .../easyflow/ai/service/FaqItemService.java | 9 + .../ai/service/impl/FaqItemServiceImpl.java | 412 ++++++++++++++++++ .../easyflow/ai/vo/FaqImportErrorRowVo.java | 41 ++ .../easyflow/ai/vo/FaqImportResultVo.java | 44 ++ .../langs/en-US/documentCollection.json | 18 +- .../langs/zh-CN/documentCollection.json | 18 +- .../ai/documentCollection/FaqImportDialog.vue | 411 +++++++++++++++++ .../views/ai/documentCollection/FaqTable.vue | 219 +++++++++- 9 files changed, 1195 insertions(+), 22 deletions(-) create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/FaqImportErrorRowVo.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/FaqImportResultVo.java create mode 100644 easyflow-ui-admin/app/src/views/ai/documentCollection/FaqImportDialog.vue diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqItemController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqItemController.java index 651cd69..4d49bc9 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqItemController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/FaqItemController.java @@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaCheckPermission; import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.query.QueryWrapper; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.MediaType; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; @@ -16,6 +17,7 @@ import tech.easyflow.ai.entity.FaqItem; import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.ai.service.FaqCategoryService; import tech.easyflow.ai.service.FaqItemService; +import tech.easyflow.ai.vo.FaqImportResultVo; import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.domain.Result; import tech.easyflow.common.filestorage.FileStorageService; @@ -27,7 +29,10 @@ import tech.easyflow.common.web.jsonbody.JsonBody; import javax.annotation.Resource; import java.io.Serializable; import java.math.BigInteger; +import java.net.URLEncoder; +import java.text.SimpleDateFormat; import java.util.Arrays; +import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -173,6 +178,46 @@ public class FaqItemController extends BaseCurdController importExcel(MultipartFile file, BigInteger collectionId) { + return Result.ok(service.importFromExcel(collectionId, file)); + } + + @GetMapping("downloadImportTemplate") + @SaCheckPermission("/api/v1/documentCollection/query") + public void downloadImportTemplate(BigInteger collectionId, HttpServletResponse response) throws Exception { + if (collectionId == null) { + throw new BusinessException("知识库ID不能为空"); + } + DocumentCollection collection = documentCollectionService.getById(collectionId); + if (collection == null) { + throw new BusinessException("知识库不存在"); + } + if (!collection.isFaqCollection()) { + throw new BusinessException("当前知识库不是FAQ类型"); + } + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + String fileName = URLEncoder.encode("faq_import_template", "UTF-8").replaceAll("\\+", "%20"); + response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); + service.writeImportTemplate(response.getOutputStream()); + } + + @GetMapping("exportExcel") + @SaCheckPermission("/api/v1/documentCollection/query") + public void exportExcel(BigInteger collectionId, HttpServletResponse response) throws Exception { + if (collectionId == null) { + throw new BusinessException("知识库ID不能为空"); + } + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + String timestamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()); + String fileName = URLEncoder.encode("faq_export_" + timestamp, "UTF-8").replaceAll("\\+", "%20"); + response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); + service.exportToExcel(collectionId, response.getOutputStream()); + } + @Override protected String getDefaultOrderBy() { return "order_no asc"; diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/FaqItemService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/FaqItemService.java index 9b8f869..917dfbb 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/FaqItemService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/FaqItemService.java @@ -1,8 +1,11 @@ package tech.easyflow.ai.service; import com.mybatisflex.core.service.IService; +import org.springframework.web.multipart.MultipartFile; import tech.easyflow.ai.entity.FaqItem; +import tech.easyflow.ai.vo.FaqImportResultVo; +import java.io.OutputStream; import java.math.BigInteger; public interface FaqItemService extends IService { @@ -12,4 +15,10 @@ public interface FaqItemService extends IService { boolean updateFaqItem(FaqItem entity); boolean removeFaqItem(BigInteger id); + + FaqImportResultVo importFromExcel(BigInteger collectionId, MultipartFile file); + + void exportToExcel(BigInteger collectionId, OutputStream outputStream); + + void writeImportTemplate(OutputStream outputStream); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqItemServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqItemServiceImpl.java index 8c5b3d6..6ffdd07 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqItemServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/FaqItemServiceImpl.java @@ -1,5 +1,13 @@ package tech.easyflow.ai.service.impl; +import cn.idev.excel.EasyExcel; +import cn.idev.excel.ExcelWriter; +import cn.idev.excel.FastExcel; +import cn.idev.excel.context.AnalysisContext; +import cn.idev.excel.metadata.data.ReadCellData; +import cn.idev.excel.read.listener.ReadListener; +import cn.idev.excel.write.metadata.WriteSheet; +import cn.idev.excel.write.style.column.SimpleColumnWidthStyleStrategy; import cn.dev33.satoken.stp.StpUtil; import com.easyagents.core.model.embedding.EmbeddingModel; import com.easyagents.core.model.embedding.EmbeddingOptions; @@ -18,6 +26,10 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.multipart.MultipartFile; import tech.easyflow.ai.config.SearcherFactory; import tech.easyflow.ai.entity.DocumentCollection; import tech.easyflow.ai.entity.FaqItem; @@ -27,12 +39,17 @@ import tech.easyflow.ai.service.DocumentCollectionService; import tech.easyflow.ai.service.FaqCategoryService; import tech.easyflow.ai.service.FaqItemService; import tech.easyflow.ai.service.ModelService; +import tech.easyflow.ai.vo.FaqImportErrorRowVo; +import tech.easyflow.ai.vo.FaqImportResultVo; import tech.easyflow.common.util.StringUtil; import tech.easyflow.common.web.exceptions.BusinessException; import javax.annotation.Resource; +import java.io.InputStream; +import java.io.OutputStream; import java.math.BigInteger; import java.util.*; +import java.util.function.Supplier; import static tech.easyflow.ai.entity.DocumentCollection.KEY_CAN_UPDATE_EMBEDDING_MODEL; import static tech.easyflow.ai.entity.DocumentCollection.KEY_SEARCH_ENGINE_TYPE; @@ -49,6 +66,15 @@ public class FaqItemServiceImpl extends ServiceImpl impl private static final String IMAGE_FIELD_URL = "url"; private static final String IMAGE_FIELD_ALT = "alt"; private static final String IMAGE_FIELD_TITLE = "title"; + private static final String IMPORT_HEAD_CATEGORY_PATH = "分类路径"; + private static final String IMPORT_HEAD_QUESTION = "问题"; + private static final String IMPORT_HEAD_ANSWER_HTML = "答案HTML"; + private static final String DEFAULT_CATEGORY_NAME = "默认分类"; + private static final String IMPORT_UPSERT_KEY_SEPARATOR = "\u0001"; + private static final int MAX_IMPORT_ROWS = 5000; + private static final long MAX_IMPORT_FILE_SIZE_BYTES = 10L * 1024L * 1024L; + private static final int TEMPLATE_COLUMN_WIDTH = 30; + private static final int TEMPLATE_NOTE_COLUMN_WIDTH = 90; @Resource private DocumentCollectionService documentCollectionService; @@ -62,6 +88,9 @@ public class FaqItemServiceImpl extends ServiceImpl impl @Autowired private SearcherFactory searcherFactory; + @Resource + private PlatformTransactionManager transactionManager; + @Override @Transactional public boolean saveFaqItem(FaqItem entity) { @@ -136,6 +165,133 @@ public class FaqItemServiceImpl extends ServiceImpl impl return removeById(id); } + @Override + public FaqImportResultVo importFromExcel(BigInteger collectionId, MultipartFile file) { + validateImportFile(file); + getFaqCollection(collectionId); + + BigInteger defaultCategoryId = faqCategoryService.ensureDefaultCategory(collectionId); + Map categoryPathMap = faqCategoryService.buildPathMap(collectionId); + String defaultCategoryPath = normalizeCategoryPath(categoryPathMap.get(defaultCategoryId)); + if (!StringUtil.hasText(defaultCategoryPath)) { + defaultCategoryPath = DEFAULT_CATEGORY_NAME; + } + + Map categoryPathIdMap = buildCategoryPathIdMap(categoryPathMap); + categoryPathIdMap.putIfAbsent(defaultCategoryPath, defaultCategoryId); + + Map existingFaqMap = buildExistingFaqMap(collectionId, categoryPathMap, defaultCategoryPath); + int nextOrderNo = nextOrderNo(collectionId); + TransactionTemplate rowTransactionTemplate = createImportRowTransactionTemplate(); + + List importRows = parseExcelRows(file); + FaqImportResultVo result = new FaqImportResultVo(); + result.setTotalCount(importRows.size()); + + for (FaqImportRow row : importRows) { + String question = trimToNull(row.getQuestion()); + String answerHtml = trimToNull(row.getAnswerHtml()); + if (!StringUtil.hasText(question)) { + appendImportError(result, row, "问题不能为空"); + continue; + } + if (!StringUtil.hasText(answerHtml)) { + appendImportError(result, row, "答案HTML不能为空"); + continue; + } + + String inputPath = normalizeCategoryPath(row.getCategoryPath()); + String resolvedCategoryPath = StringUtil.hasText(inputPath) ? inputPath : defaultCategoryPath; + BigInteger resolvedCategoryId = categoryPathIdMap.get(resolvedCategoryPath); + if (resolvedCategoryId == null) { + resolvedCategoryId = defaultCategoryId; + resolvedCategoryPath = defaultCategoryPath; + } + String upsertKey = buildImportUpsertKey(resolvedCategoryPath, question); + FaqItem matchedFaq = existingFaqMap.get(upsertKey); + Integer targetOrderNo = matchedFaq == null ? nextOrderNo : matchedFaq.getOrderNo(); + if (matchedFaq == null) { + nextOrderNo++; + } + + try { + if (matchedFaq == null) { + FaqItem saveEntity = new FaqItem(); + saveEntity.setCollectionId(collectionId); + saveEntity.setCategoryId(resolvedCategoryId); + saveEntity.setQuestion(question); + saveEntity.setAnswerHtml(answerHtml); + saveEntity.setOrderNo(targetOrderNo); + executeInRowTransaction(rowTransactionTemplate, () -> saveFaqItem(saveEntity)); + existingFaqMap.put(upsertKey, saveEntity); + } else { + FaqItem updateEntity = new FaqItem(); + updateEntity.setId(matchedFaq.getId()); + updateEntity.setCollectionId(collectionId); + updateEntity.setCategoryId(resolvedCategoryId); + updateEntity.setQuestion(question); + updateEntity.setAnswerHtml(answerHtml); + updateEntity.setOrderNo(targetOrderNo); + executeInRowTransaction(rowTransactionTemplate, () -> updateFaqItem(updateEntity)); + matchedFaq.setCategoryId(resolvedCategoryId); + matchedFaq.setQuestion(question); + matchedFaq.setAnswerHtml(answerHtml); + matchedFaq.setOrderNo(targetOrderNo); + existingFaqMap.put(upsertKey, matchedFaq); + } + result.setSuccessCount(result.getSuccessCount() + 1); + } catch (Exception e) { + appendImportError(result, row, extractImportErrorMessage(e)); + } + } + + result.setErrorCount(result.getErrorRows().size()); + return result; + } + + @Override + public void exportToExcel(BigInteger collectionId, OutputStream outputStream) { + getFaqCollection(collectionId); + faqCategoryService.ensureDefaultCategory(collectionId); + Map categoryPathMap = faqCategoryService.buildPathMap(collectionId); + List faqItems = list(QueryWrapper.create() + .eq(FaqItem::getCollectionId, collectionId) + .orderBy("order_no asc, id asc")); + List> rows = new ArrayList<>(); + for (FaqItem faqItem : faqItems) { + List row = new ArrayList<>(3); + row.add(defaultIfNull(normalizeCategoryPath(categoryPathMap.get(faqItem.getCategoryId())), DEFAULT_CATEGORY_NAME)); + row.add(defaultIfNull(faqItem.getQuestion(), "")); + row.add(defaultIfNull(faqItem.getAnswerHtml(), "")); + rows.add(row); + } + EasyExcel.write(outputStream) + .head(buildImportHeadList()) + .registerWriteHandler(new SimpleColumnWidthStyleStrategy(TEMPLATE_COLUMN_WIDTH)) + .sheet("FAQ") + .doWrite(rows); + } + + @Override + public void writeImportTemplate(OutputStream outputStream) { + ExcelWriter excelWriter = EasyExcel.write(outputStream).build(); + try { + WriteSheet templateSheet = EasyExcel.writerSheet(0, "模板") + .head(buildImportHeadList()) + .registerWriteHandler(new SimpleColumnWidthStyleStrategy(TEMPLATE_COLUMN_WIDTH)) + .build(); + excelWriter.write(new ArrayList<>(), templateSheet); + + WriteSheet instructionSheet = EasyExcel.writerSheet(1, "填写说明") + .head(Collections.singletonList(Collections.singletonList("规则说明"))) + .registerWriteHandler(new SimpleColumnWidthStyleStrategy(TEMPLATE_NOTE_COLUMN_WIDTH)) + .build(); + excelWriter.write(buildImportInstructionRows(), instructionSheet); + } finally { + excelWriter.finish(); + } + } + private void checkAndNormalize(FaqItem entity, boolean isSave, Map baseOptions) { if (entity == null) { throw new BusinessException("FAQ条目不能为空"); @@ -177,6 +333,9 @@ public class FaqItemServiceImpl extends ServiceImpl impl } private DocumentCollection getFaqCollection(BigInteger collectionId) { + if (collectionId == null) { + throw new BusinessException("知识库ID不能为空"); + } DocumentCollection collection = documentCollectionService.getById(collectionId); if (collection == null) { throw new BusinessException("知识库不存在"); @@ -321,6 +480,159 @@ public class FaqItemServiceImpl extends ServiceImpl impl } } + private List parseExcelRows(MultipartFile file) { + try (InputStream inputStream = file.getInputStream()) { + FaqExcelReadListener listener = new FaqExcelReadListener(); + FastExcel.read(inputStream, listener) + .sheet() + .doRead(); + return listener.getRows(); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + throw new BusinessException("FAQ Excel解析失败"); + } + } + + private void validateImportFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException("导入文件不能为空"); + } + if (file.getSize() > MAX_IMPORT_FILE_SIZE_BYTES) { + throw new BusinessException("导入文件大小不能超过10MB"); + } + String fileName = file.getOriginalFilename(); + String lowerName = fileName == null ? "" : fileName.toLowerCase(); + if (!(lowerName.endsWith(".xlsx") || lowerName.endsWith(".xls"))) { + throw new BusinessException("仅支持 xlsx/xls 文件"); + } + } + + private List> buildImportHeadList() { + List> headList = new ArrayList<>(3); + headList.add(Collections.singletonList(IMPORT_HEAD_CATEGORY_PATH)); + headList.add(Collections.singletonList(IMPORT_HEAD_QUESTION)); + headList.add(Collections.singletonList(IMPORT_HEAD_ANSWER_HTML)); + return headList; + } + + private List> buildImportInstructionRows() { + List> rows = new ArrayList<>(); + rows.add(Collections.singletonList("1. 模板页为第一个Sheet(名称:模板),导入时将读取该Sheet。")); + rows.add(Collections.singletonList("2. 分类路径可为空;为空或不存在时会自动回落到“默认分类”。")); + rows.add(Collections.singletonList("3. 分类路径分隔符使用“/”,示例:产品/账号/登录。")); + rows.add(Collections.singletonList("4. 问题不能为空;答案HTML不能为空。")); + rows.add(Collections.singletonList("5. 导入按“分类路径 + 问题”执行更新/新增(Upsert)。")); + rows.add(Collections.singletonList("6. 同一个Excel里若键重复,后出现的行会覆盖前面的行。")); + rows.add(Collections.singletonList("7. 单次最多导入5000条数据,文件大小不超过10MB。")); + return rows; + } + + private void appendImportError(FaqImportResultVo result, FaqImportRow row, String reason) { + FaqImportErrorRowVo errorRow = new FaqImportErrorRowVo(); + errorRow.setRowNumber(row.getRowNumber()); + errorRow.setCategoryPath(row.getCategoryPath()); + errorRow.setQuestion(row.getQuestion()); + errorRow.setReason(reason); + result.getErrorRows().add(errorRow); + } + + private String extractImportErrorMessage(Exception e) { + if (e == null) { + return "导入失败"; + } + if (e.getCause() != null && StringUtil.hasText(e.getCause().getMessage())) { + return e.getCause().getMessage(); + } + if (StringUtil.hasText(e.getMessage())) { + return e.getMessage(); + } + return "导入失败"; + } + + private TransactionTemplate createImportRowTransactionTemplate() { + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return template; + } + + private boolean executeInRowTransaction(TransactionTemplate template, Supplier action) { + Boolean success = template.execute(status -> { + boolean saved = action.get(); + if (!saved) { + status.setRollbackOnly(); + return false; + } + return true; + }); + if (!Boolean.TRUE.equals(success)) { + throw new BusinessException("FAQ导入保存失败"); + } + return true; + } + + private Map buildCategoryPathIdMap(Map categoryPathMap) { + Map pathIdMap = new HashMap<>(); + for (Map.Entry entry : categoryPathMap.entrySet()) { + if (entry.getKey() == null) { + continue; + } + String normalizedPath = normalizeCategoryPath(entry.getValue()); + if (!StringUtil.hasText(normalizedPath)) { + continue; + } + pathIdMap.put(normalizedPath, entry.getKey()); + } + return pathIdMap; + } + + private Map buildExistingFaqMap(BigInteger collectionId, + Map categoryPathMap, + String defaultCategoryPath) { + Map existingFaqMap = new HashMap<>(); + List existingFaqItems = list(QueryWrapper.create() + .eq(FaqItem::getCollectionId, collectionId) + .orderBy("order_no asc, id asc")); + for (FaqItem faqItem : existingFaqItems) { + if (faqItem.getId() == null || StringUtil.noText(faqItem.getQuestion())) { + continue; + } + String categoryPath = normalizeCategoryPath(categoryPathMap.get(faqItem.getCategoryId())); + if (!StringUtil.hasText(categoryPath)) { + categoryPath = defaultCategoryPath; + } + String upsertKey = buildImportUpsertKey(categoryPath, faqItem.getQuestion()); + existingFaqMap.putIfAbsent(upsertKey, faqItem); + } + return existingFaqMap; + } + + private String buildImportUpsertKey(String categoryPath, String question) { + return defaultIfNull(categoryPath, DEFAULT_CATEGORY_NAME) + IMPORT_UPSERT_KEY_SEPARATOR + question.trim(); + } + + private String normalizeCategoryPath(String categoryPath) { + if (!StringUtil.hasText(categoryPath)) { + return null; + } + String[] nodes = categoryPath.trim().split("/"); + List normalized = new ArrayList<>(); + for (String node : nodes) { + String trimmed = trimToNull(node); + if (trimmed != null) { + normalized.add(trimmed); + } + } + if (normalized.isEmpty()) { + return null; + } + return String.join(" / ", normalized); + } + + private String defaultIfNull(String value, String fallback) { + return value == null ? fallback : value; + } + private Integer nextOrderNo(BigInteger collectionId) { java.util.List list = list(QueryWrapper.create() .eq(FaqItem::getCollectionId, collectionId) @@ -454,6 +766,106 @@ public class FaqItemServiceImpl extends ServiceImpl impl return value.trim(); } + private static class FaqImportRow { + private Integer rowNumber; + private String categoryPath; + private String question; + private String answerHtml; + + public Integer getRowNumber() { + return rowNumber; + } + + public void setRowNumber(Integer rowNumber) { + this.rowNumber = rowNumber; + } + + public String getCategoryPath() { + return categoryPath; + } + + public void setCategoryPath(String categoryPath) { + this.categoryPath = categoryPath; + } + + public String getQuestion() { + return question; + } + + public void setQuestion(String question) { + this.question = question; + } + + public String getAnswerHtml() { + return answerHtml; + } + + public void setAnswerHtml(String answerHtml) { + this.answerHtml = answerHtml; + } + } + + private class FaqExcelReadListener implements ReadListener> { + private final Map headIndex = new HashMap<>(); + private final List rows = new ArrayList<>(); + private int sheetRowNo; + + @Override + public void invoke(LinkedHashMap data, AnalysisContext context) { + sheetRowNo++; + String categoryPath = getCellValue(data, IMPORT_HEAD_CATEGORY_PATH); + String question = getCellValue(data, IMPORT_HEAD_QUESTION); + String answerHtml = getCellValue(data, IMPORT_HEAD_ANSWER_HTML); + if (!StringUtil.hasText(categoryPath) && !StringUtil.hasText(question) && !StringUtil.hasText(answerHtml)) { + return; + } + if (rows.size() >= MAX_IMPORT_ROWS) { + throw new BusinessException("单次最多导入5000条FAQ"); + } + FaqImportRow row = new FaqImportRow(); + row.setRowNumber(sheetRowNo + 1); + row.setCategoryPath(categoryPath); + row.setQuestion(question); + row.setAnswerHtml(answerHtml); + rows.add(row); + } + + @Override + public void invokeHead(Map> headMap, AnalysisContext context) { + for (Map.Entry> entry : headMap.entrySet()) { + String headValue = entry.getValue() == null ? null : entry.getValue().getStringValue(); + String header = trimToNull(headValue); + if (header == null) { + continue; + } + headIndex.put(header, entry.getKey()); + } + if (!headIndex.containsKey(IMPORT_HEAD_CATEGORY_PATH) + || !headIndex.containsKey(IMPORT_HEAD_QUESTION) + || !headIndex.containsKey(IMPORT_HEAD_ANSWER_HTML)) { + throw new BusinessException("导入模板表头不正确,必须包含:分类路径、问题、答案HTML"); + } + } + + @Override + public void doAfterAllAnalysed(AnalysisContext context) { + // no-op + } + + public List getRows() { + return rows; + } + + private String getCellValue(Map row, String headName) { + Integer index = headIndex.get(headName); + if (index == null) { + return null; + } + Object value = row.get(index); + return value == null ? null : String.valueOf(value).trim(); + } + } + private static class PreparedStore { private final DocumentStore documentStore; private final StoreOptions storeOptions; diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/FaqImportErrorRowVo.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/FaqImportErrorRowVo.java new file mode 100644 index 0000000..5d6c089 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/FaqImportErrorRowVo.java @@ -0,0 +1,41 @@ +package tech.easyflow.ai.vo; + +public class FaqImportErrorRowVo { + + private Integer rowNumber; + private String categoryPath; + private String question; + private String reason; + + public Integer getRowNumber() { + return rowNumber; + } + + public void setRowNumber(Integer rowNumber) { + this.rowNumber = rowNumber; + } + + public String getCategoryPath() { + return categoryPath; + } + + public void setCategoryPath(String categoryPath) { + this.categoryPath = categoryPath; + } + + public String getQuestion() { + return question; + } + + public void setQuestion(String question) { + this.question = question; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/FaqImportResultVo.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/FaqImportResultVo.java new file mode 100644 index 0000000..db3caa1 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/FaqImportResultVo.java @@ -0,0 +1,44 @@ +package tech.easyflow.ai.vo; + +import java.util.ArrayList; +import java.util.List; + +public class FaqImportResultVo { + + private int totalCount; + private int successCount; + private int errorCount; + private List errorRows = new ArrayList<>(); + + public int getTotalCount() { + return totalCount; + } + + public void setTotalCount(int totalCount) { + this.totalCount = totalCount; + } + + public int getSuccessCount() { + return successCount; + } + + public void setSuccessCount(int successCount) { + this.successCount = successCount; + } + + public int getErrorCount() { + return errorCount; + } + + public void setErrorCount(int errorCount) { + this.errorCount = errorCount; + } + + public List getErrorRows() { + return errorRows; + } + + public void setErrorRows(List errorRows) { + this.errorRows = errorRows; + } +} diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json index 962c47c..0ce26d9 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollection.json @@ -112,7 +112,23 @@ "collectionRequired": "Collection id is required", "imageTypeInvalid": "Only JPG/PNG/WEBP/GIF images are supported", "imageSizeExceeded": "Image size must be less than 5MB", - "imageUploadFailed": "Image upload failed" + "imageUploadFailed": "Image upload failed", + "import": { + "title": "Import FAQ", + "uploadTitle": "Drop Excel file here or click to upload", + "uploadDesc": "Only .xlsx/.xls files are supported, max 5000 rows in first sheet", + "moreActions": "More Actions", + "downloadTemplate": "Download Template", + "startImport": "Start Import", + "importFinished": "Import completed", + "resultTitle": "Import Result", + "totalCount": "Total", + "successCount": "Success", + "errorCount": "Failed", + "rowNumber": "Row", + "reason": "Reason", + "selectFileRequired": "Please select an Excel file first" + } }, "searchResults": "SearchResults", "documentPreview": "DocumentPreview", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json index 1793b04..a99a814 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollection.json @@ -112,7 +112,23 @@ "collectionRequired": "知识库ID不能为空", "imageTypeInvalid": "仅支持 JPG/PNG/WEBP/GIF 图片", "imageSizeExceeded": "图片大小不能超过5MB", - "imageUploadFailed": "图片上传失败" + "imageUploadFailed": "图片上传失败", + "import": { + "title": "导入FAQ", + "uploadTitle": "将 Excel 文件拖拽到此处,或点击上传", + "uploadDesc": "仅支持 .xlsx/.xls,首个工作表最多5000条", + "moreActions": "更多操作", + "downloadTemplate": "下载导入模板", + "startImport": "开始导入", + "importFinished": "导入完成", + "resultTitle": "导入结果", + "totalCount": "总数", + "successCount": "成功数", + "errorCount": "失败数", + "rowNumber": "行号", + "reason": "失败原因", + "selectFileRequired": "请先选择Excel文件" + } }, "searchResults": "检索结果", "documentPreview": "文档预览", diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqImportDialog.vue b/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqImportDialog.vue new file mode 100644 index 0000000..a678558 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqImportDialog.vue @@ -0,0 +1,411 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqTable.vue b/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqTable.vue index a50959d..0f94467 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqTable.vue +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/FaqTable.vue @@ -2,6 +2,7 @@ import { computed, onMounted, ref } from 'vue'; import { $t } from '@easyflow/locales'; +import { downloadFileFromBlob } from '@easyflow/utils'; import { Bottom, @@ -12,6 +13,8 @@ import { FolderAdd, MoreFilled, Plus, + RefreshRight, + Search, Top, Upload, } from '@element-plus/icons-vue'; @@ -22,17 +25,18 @@ import { ElDropdownMenu, ElMessage, ElMessageBox, + ElInput, ElTable, ElTableColumn, ElTree, } from 'element-plus'; import { api } from '#/api/request'; -import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue'; import PageData from '#/components/page/PageData.vue'; import FaqCategoryDialog from './FaqCategoryDialog.vue'; import FaqEditDialog from './FaqEditDialog.vue'; +import FaqImportDialog from './FaqImportDialog.vue'; const props = defineProps({ knowledgeId: { @@ -53,20 +57,14 @@ const searchKeyword = ref(''); const categoryTree = ref([]); const categoryParentOptions = ref([]); const categoryActionLoading = ref(false); +const importDialogVisible = ref(false); +const templateDownloadLoading = ref(false); +const exportLoading = ref(false); const baseQueryParams = ref({ collectionId: props.knowledgeId, }); -const headerButtons = [ - { - key: 'add', - text: $t('button.add'), - icon: Plus, - type: 'primary', - }, -]; - const treeData = computed(() => [ { id: 'all', @@ -134,8 +132,13 @@ const hasCategoryId = (nodes: any[], id: string): boolean => { return false; }; -const handleSearch = (keyword: string) => { - searchKeyword.value = keyword || ''; +const handleSearch = () => { + searchKeyword.value = searchKeyword.value.trim(); + refreshList(); +}; + +const handleResetSearch = () => { + searchKeyword.value = ''; refreshList(); }; @@ -150,9 +153,57 @@ const openAddDialog = () => { dialogVisible.value = true; }; -const handleButtonClick = (event: any) => { - if (event.key === 'add') { - openAddDialog(); +const downloadImportTemplate = async () => { + if (templateDownloadLoading.value) { + return; + } + templateDownloadLoading.value = true; + try { + const blob = await api.download( + `/api/v1/faqItem/downloadImportTemplate?collectionId=${props.knowledgeId}`, + ); + downloadFileFromBlob({ + fileName: 'faq_import_template.xlsx', + source: blob, + }); + } finally { + templateDownloadLoading.value = false; + } +}; + +const exportFaqExcel = async () => { + if (exportLoading.value) { + return; + } + exportLoading.value = true; + try { + const blob = await api.download( + `/api/v1/faqItem/exportExcel?collectionId=${props.knowledgeId}`, + ); + downloadFileFromBlob({ + fileName: 'faq_export.xlsx', + source: blob, + }); + } finally { + exportLoading.value = false; + } +}; + +const handleImportSuccess = () => { + refreshList(); +}; + +const handleMoreActionCommand = (command: string) => { + if (command === 'import') { + importDialogVisible.value = true; + return; + } + if (command === 'downloadTemplate') { + downloadImportTemplate(); + return; + } + if (command === 'export') { + exportFaqExcel(); } }; @@ -740,11 +791,58 @@ onMounted(() => {
- +
+
+ + + {{ $t('button.query') }} + + + {{ $t('button.reset') }} + +
+ +
+ + {{ $t('button.add') }} + + + + {{ $t('documentCollection.faq.import.moreActions') }} + + + +
+
{ :parent-options="categoryParentOptions" @submit="saveCategory" /> + +
@@ -898,6 +1002,65 @@ onMounted(() => { margin-bottom: 12px; } +.faq-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 2px 0; +} + +.faq-search-actions { + flex: 1; + min-width: 260px; + display: flex; + align-items: center; + gap: 10px; +} + +.faq-search-input { + width: min(460px, 100%); +} + +.faq-primary-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + +:deep(.faq-toolbar .el-button) { + height: 38px; + padding: 0 16px; + border-radius: 10px; + font-weight: 500; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + color 0.2s ease; +} + +:deep(.faq-toolbar .el-button:not(.el-button--primary):hover) { + color: hsl(var(--primary)); + border-color: hsl(var(--primary) / 45%); + background: hsl(var(--primary) / 7%); +} + +:deep(.faq-search-input .el-input__wrapper) { + border-radius: 10px; + box-shadow: 0 0 0 1px var(--el-border-color) inset; + padding-left: 12px; + padding-right: 12px; +} + +:deep(.faq-search-input .el-input__wrapper:hover) { + box-shadow: 0 0 0 1px hsl(var(--primary) / 30%) inset; +} + +:deep(.faq-search-input .el-input__wrapper.is-focus) { + box-shadow: 0 0 0 1px hsl(var(--primary) / 60%) inset; +} + :deep( .faq-category-tree > .el-tree-node > .el-tree-node__content > .el-tree-node__expand-icon ) { @@ -940,4 +1103,20 @@ onMounted(() => { padding-top: 14px; padding-bottom: 14px; } + +@media (max-width: 1360px) { + .faq-toolbar { + align-items: flex-start; + flex-direction: column; + } + + .faq-search-actions, + .faq-primary-actions { + width: 100%; + } + + .faq-primary-actions { + justify-content: flex-start; + } +}