feat(ai): add three-level FAQ category management
- add FAQ category table/sql migration and initialize ddl updates - add category service/controller with validation, default category rules, and sorting - support faq item category binding and category-based filtering (include descendants) - redesign FAQ page with category tree actions and UI polish
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import tech.easyflow.ai.entity.FaqCategory;
|
||||||
|
import tech.easyflow.ai.service.FaqCategoryService;
|
||||||
|
import tech.easyflow.common.annotation.UsePermission;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/faqCategory")
|
||||||
|
@UsePermission(moduleName = "/api/v1/documentCollection")
|
||||||
|
public class FaqCategoryController extends BaseCurdController<FaqCategoryService, FaqCategory> {
|
||||||
|
|
||||||
|
public FaqCategoryController(FaqCategoryService service) {
|
||||||
|
super(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@GetMapping("list")
|
||||||
|
@SaCheckPermission("/api/v1/documentCollection/query")
|
||||||
|
public Result<List<FaqCategory>> list(FaqCategory entity, Boolean asTree, String sortKey, String sortType) {
|
||||||
|
BigInteger collectionId = entity == null ? null : entity.getCollectionId();
|
||||||
|
if (collectionId == null) {
|
||||||
|
throw new BusinessException("知识库ID不能为空");
|
||||||
|
}
|
||||||
|
return Result.ok(service.listByCollection(collectionId, asTree));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PostMapping("save")
|
||||||
|
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||||
|
public Result<?> save(@JsonBody FaqCategory entity) {
|
||||||
|
return Result.ok(service.saveCategory(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PostMapping("update")
|
||||||
|
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||||
|
public Result<?> update(@JsonBody FaqCategory entity) {
|
||||||
|
return Result.ok(service.updateCategory(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PostMapping("remove")
|
||||||
|
@SaCheckPermission("/api/v1/documentCollection/remove")
|
||||||
|
public Result<?> remove(@JsonBody(value = "id", required = true) Serializable id) {
|
||||||
|
return Result.ok(service.removeCategory(new BigInteger(String.valueOf(id))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getDefaultOrderBy() {
|
||||||
|
return "sort_no asc";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,27 +2,36 @@ package tech.easyflow.admin.controller.ai;
|
|||||||
|
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import com.mybatisflex.core.paginate.Page;
|
import com.mybatisflex.core.paginate.Page;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import tech.easyflow.ai.entity.FaqItem;
|
import tech.easyflow.ai.entity.FaqItem;
|
||||||
|
import tech.easyflow.ai.service.FaqCategoryService;
|
||||||
import tech.easyflow.ai.service.FaqItemService;
|
import tech.easyflow.ai.service.FaqItemService;
|
||||||
import tech.easyflow.common.annotation.UsePermission;
|
import tech.easyflow.common.annotation.UsePermission;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/faqItem")
|
@RequestMapping("/api/v1/faqItem")
|
||||||
@UsePermission(moduleName = "/api/v1/documentCollection")
|
@UsePermission(moduleName = "/api/v1/documentCollection")
|
||||||
public class FaqItemController extends BaseCurdController<FaqItemService, FaqItem> {
|
public class FaqItemController extends BaseCurdController<FaqItemService, FaqItem> {
|
||||||
|
|
||||||
public FaqItemController(FaqItemService service) {
|
private final FaqCategoryService faqCategoryService;
|
||||||
|
|
||||||
|
public FaqItemController(FaqItemService service, FaqCategoryService faqCategoryService) {
|
||||||
super(service);
|
super(service);
|
||||||
|
this.faqCategoryService = faqCategoryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -36,7 +45,49 @@ public class FaqItemController extends BaseCurdController<FaqItemService, FaqIte
|
|||||||
@GetMapping("page")
|
@GetMapping("page")
|
||||||
@SaCheckPermission("/api/v1/documentCollection/query")
|
@SaCheckPermission("/api/v1/documentCollection/query")
|
||||||
public Result<Page<FaqItem>> page(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {
|
public Result<Page<FaqItem>> page(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {
|
||||||
return super.page(request, sortKey, sortType, pageNumber, pageSize);
|
if (pageNumber == null || pageNumber < 1) {
|
||||||
|
pageNumber = 1L;
|
||||||
|
}
|
||||||
|
if (pageSize == null || pageSize < 1) {
|
||||||
|
pageSize = 10L;
|
||||||
|
}
|
||||||
|
|
||||||
|
String collectionIdText = request.getParameter("collectionId");
|
||||||
|
if (collectionIdText == null || collectionIdText.trim().isEmpty()) {
|
||||||
|
throw new BusinessException("知识库ID不能为空");
|
||||||
|
}
|
||||||
|
BigInteger collectionId = new BigInteger(collectionIdText);
|
||||||
|
faqCategoryService.ensureDefaultCategory(collectionId);
|
||||||
|
|
||||||
|
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||||
|
.eq(FaqItem::getCollectionId, collectionId);
|
||||||
|
|
||||||
|
String question = request.getParameter("question");
|
||||||
|
if (question != null && !question.trim().isEmpty()) {
|
||||||
|
queryWrapper.like(FaqItem::getQuestion, question.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
String categoryIdText = request.getParameter("categoryId");
|
||||||
|
if (categoryIdText != null && !categoryIdText.trim().isEmpty()) {
|
||||||
|
BigInteger categoryId = new BigInteger(categoryIdText);
|
||||||
|
List<BigInteger> descendantIds = faqCategoryService.findDescendantIds(collectionId, categoryId);
|
||||||
|
if (descendantIds.isEmpty()) {
|
||||||
|
queryWrapper.eq(FaqItem::getId, BigInteger.ZERO);
|
||||||
|
} else {
|
||||||
|
queryWrapper.in(FaqItem::getCategoryId, descendantIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
|
||||||
|
Page<FaqItem> page = service.page(new Page<>(pageNumber, pageSize), queryWrapper);
|
||||||
|
Map<BigInteger, String> pathMap = faqCategoryService.buildPathMap(collectionId);
|
||||||
|
|
||||||
|
if (page.getRecords() != null) {
|
||||||
|
for (FaqItem record : page.getRecords()) {
|
||||||
|
record.setCategoryPath(pathMap.get(record.getCategoryId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result.ok(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package tech.easyflow.ai.entity;
|
||||||
|
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import tech.easyflow.ai.entity.base.FaqCategoryBase;
|
||||||
|
|
||||||
|
@Table("tb_faq_category")
|
||||||
|
public class FaqCategory extends FaqCategoryBase {
|
||||||
|
}
|
||||||
@@ -1,8 +1,31 @@
|
|||||||
package tech.easyflow.ai.entity;
|
package tech.easyflow.ai.entity;
|
||||||
|
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
import com.mybatisflex.annotation.Table;
|
import com.mybatisflex.annotation.Table;
|
||||||
import tech.easyflow.ai.entity.base.FaqItemBase;
|
import tech.easyflow.ai.entity.base.FaqItemBase;
|
||||||
|
|
||||||
@Table("tb_faq_item")
|
@Table("tb_faq_item")
|
||||||
public class FaqItem extends FaqItemBase {
|
public class FaqItem extends FaqItemBase {
|
||||||
|
|
||||||
|
@Column(ignore = true)
|
||||||
|
private String categoryName;
|
||||||
|
|
||||||
|
@Column(ignore = true)
|
||||||
|
private String categoryPath;
|
||||||
|
|
||||||
|
public String getCategoryName() {
|
||||||
|
return categoryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategoryName(String categoryName) {
|
||||||
|
this.categoryName = categoryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCategoryPath() {
|
||||||
|
return categoryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategoryPath(String categoryPath) {
|
||||||
|
this.categoryPath = categoryPath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package tech.easyflow.ai.entity.base;
|
||||||
|
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
|
import com.mybatisflex.annotation.Id;
|
||||||
|
import com.mybatisflex.annotation.KeyType;
|
||||||
|
import tech.easyflow.common.entity.DateTreeEntity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
public class FaqCategoryBase extends DateTreeEntity implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id(keyType = KeyType.Generator, value = "snowFlakeId", comment = "主键")
|
||||||
|
private BigInteger id;
|
||||||
|
|
||||||
|
@Column(comment = "知识库ID")
|
||||||
|
private BigInteger collectionId;
|
||||||
|
|
||||||
|
@Column(comment = "父分类ID,0表示根")
|
||||||
|
private BigInteger parentId;
|
||||||
|
|
||||||
|
@Column(comment = "祖先路径(逗号分隔)")
|
||||||
|
private String ancestors;
|
||||||
|
|
||||||
|
@Column(comment = "层级(1-3)")
|
||||||
|
private Integer levelNo;
|
||||||
|
|
||||||
|
@Column(comment = "分类名称")
|
||||||
|
private String categoryName;
|
||||||
|
|
||||||
|
@Column(comment = "排序")
|
||||||
|
private Integer sortNo;
|
||||||
|
|
||||||
|
@Column(comment = "是否默认分类")
|
||||||
|
private Boolean isDefault;
|
||||||
|
|
||||||
|
@Column(comment = "数据状态")
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
@Column(comment = "创建时间")
|
||||||
|
private Date created;
|
||||||
|
|
||||||
|
@Column(comment = "创建人")
|
||||||
|
private BigInteger createdBy;
|
||||||
|
|
||||||
|
@Column(comment = "更新时间")
|
||||||
|
private Date modified;
|
||||||
|
|
||||||
|
@Column(comment = "更新人")
|
||||||
|
private BigInteger modifiedBy;
|
||||||
|
|
||||||
|
public BigInteger getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(BigInteger id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getCollectionId() {
|
||||||
|
return collectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCollectionId(BigInteger collectionId) {
|
||||||
|
this.collectionId = collectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getParentId() {
|
||||||
|
return parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setParentId(BigInteger parentId) {
|
||||||
|
this.parentId = parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAncestors() {
|
||||||
|
return ancestors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAncestors(String ancestors) {
|
||||||
|
this.ancestors = ancestors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getLevelNo() {
|
||||||
|
return levelNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLevelNo(Integer levelNo) {
|
||||||
|
this.levelNo = levelNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCategoryName() {
|
||||||
|
return categoryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategoryName(String categoryName) {
|
||||||
|
this.categoryName = categoryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getSortNo() {
|
||||||
|
return sortNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSortNo(Integer sortNo) {
|
||||||
|
this.sortNo = sortNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsDefault() {
|
||||||
|
return isDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsDefault(Boolean isDefault) {
|
||||||
|
this.isDefault = isDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(Integer status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Date getCreated() {
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreated(Date created) {
|
||||||
|
this.created = created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getCreatedBy() {
|
||||||
|
return createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedBy(BigInteger createdBy) {
|
||||||
|
this.createdBy = createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Date getModified() {
|
||||||
|
return modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setModified(Date modified) {
|
||||||
|
this.modified = modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getModifiedBy() {
|
||||||
|
return modifiedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setModifiedBy(BigInteger modifiedBy) {
|
||||||
|
this.modifiedBy = modifiedBy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ public class FaqItemBase implements Serializable {
|
|||||||
@Column(comment = "知识库ID")
|
@Column(comment = "知识库ID")
|
||||||
private BigInteger collectionId;
|
private BigInteger collectionId;
|
||||||
|
|
||||||
|
@Column(comment = "FAQ分类ID")
|
||||||
|
private BigInteger categoryId;
|
||||||
|
|
||||||
@Column(comment = "问题")
|
@Column(comment = "问题")
|
||||||
private String question;
|
private String question;
|
||||||
|
|
||||||
@@ -63,6 +66,14 @@ public class FaqItemBase implements Serializable {
|
|||||||
this.collectionId = collectionId;
|
this.collectionId = collectionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public BigInteger getCategoryId() {
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategoryId(BigInteger categoryId) {
|
||||||
|
this.categoryId = categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
public String getQuestion() {
|
public String getQuestion() {
|
||||||
return question;
|
return question;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package tech.easyflow.ai.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import tech.easyflow.ai.entity.FaqCategory;
|
||||||
|
|
||||||
|
public interface FaqCategoryMapper extends BaseMapper<FaqCategory> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package tech.easyflow.ai.service;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.service.IService;
|
||||||
|
import tech.easyflow.ai.entity.FaqCategory;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public interface FaqCategoryService extends IService<FaqCategory> {
|
||||||
|
|
||||||
|
BigInteger ensureDefaultCategory(BigInteger collectionId);
|
||||||
|
|
||||||
|
BigInteger ensureFaqItemCategory(BigInteger collectionId, BigInteger categoryId);
|
||||||
|
|
||||||
|
List<FaqCategory> listByCollection(BigInteger collectionId, Boolean asTree);
|
||||||
|
|
||||||
|
boolean saveCategory(FaqCategory entity);
|
||||||
|
|
||||||
|
boolean updateCategory(FaqCategory entity);
|
||||||
|
|
||||||
|
boolean removeCategory(BigInteger id);
|
||||||
|
|
||||||
|
List<BigInteger> findDescendantIds(BigInteger collectionId, BigInteger categoryId);
|
||||||
|
|
||||||
|
Map<BigInteger, String> buildPathMap(BigInteger collectionId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
package tech.easyflow.ai.service.impl;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
|
import tech.easyflow.ai.entity.FaqCategory;
|
||||||
|
import tech.easyflow.ai.entity.FaqItem;
|
||||||
|
import tech.easyflow.ai.mapper.FaqCategoryMapper;
|
||||||
|
import tech.easyflow.ai.mapper.FaqItemMapper;
|
||||||
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
|
import tech.easyflow.ai.service.FaqCategoryService;
|
||||||
|
import tech.easyflow.common.tree.Tree;
|
||||||
|
import tech.easyflow.common.util.StringUtil;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class FaqCategoryServiceImpl extends ServiceImpl<FaqCategoryMapper, FaqCategory> implements FaqCategoryService {
|
||||||
|
|
||||||
|
private static final BigInteger ROOT_PARENT_ID = BigInteger.ZERO;
|
||||||
|
private static final ConcurrentHashMap<BigInteger, Object> DEFAULT_CATEGORY_LOCKS = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private DocumentCollectionService documentCollectionService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private FaqItemMapper faqItemMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public BigInteger ensureDefaultCategory(BigInteger collectionId) {
|
||||||
|
Object lock = DEFAULT_CATEGORY_LOCKS.computeIfAbsent(collectionId, key -> new Object());
|
||||||
|
synchronized (lock) {
|
||||||
|
try {
|
||||||
|
return ensureDefaultCategoryWithLock(collectionId);
|
||||||
|
} finally {
|
||||||
|
DEFAULT_CATEGORY_LOCKS.remove(collectionId, lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigInteger ensureDefaultCategoryWithLock(BigInteger collectionId) {
|
||||||
|
DocumentCollection collection = checkFaqCollection(collectionId);
|
||||||
|
|
||||||
|
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||||
|
.eq(FaqCategory::getCollectionId, collection.getId())
|
||||||
|
.eq(FaqCategory::getIsDefault, true)
|
||||||
|
.orderBy("id asc");
|
||||||
|
List<FaqCategory> defaultCategories = list(queryWrapper);
|
||||||
|
FaqCategory defaultCategory;
|
||||||
|
if (defaultCategories == null || defaultCategories.isEmpty()) {
|
||||||
|
Date now = new Date();
|
||||||
|
BigInteger userId = getCurrentUserId();
|
||||||
|
FaqCategory entity = new FaqCategory();
|
||||||
|
entity.setCollectionId(collectionId);
|
||||||
|
entity.setParentId(ROOT_PARENT_ID);
|
||||||
|
entity.setAncestors("0");
|
||||||
|
entity.setLevelNo(1);
|
||||||
|
entity.setCategoryName("默认分类");
|
||||||
|
entity.setSortNo(0);
|
||||||
|
entity.setIsDefault(true);
|
||||||
|
entity.setStatus(0);
|
||||||
|
entity.setCreated(now);
|
||||||
|
entity.setCreatedBy(userId);
|
||||||
|
entity.setModified(now);
|
||||||
|
entity.setModifiedBy(userId);
|
||||||
|
save(entity);
|
||||||
|
defaultCategory = entity;
|
||||||
|
} else {
|
||||||
|
defaultCategory = defaultCategories.get(0);
|
||||||
|
if (defaultCategories.size() > 1) {
|
||||||
|
normalizeDuplicateDefaultCategories(collectionId, defaultCategory, defaultCategories);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
backfillNullFaqItemCategory(collectionId, defaultCategory.getId());
|
||||||
|
return defaultCategory.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void normalizeDuplicateDefaultCategories(BigInteger collectionId,
|
||||||
|
FaqCategory keepCategory,
|
||||||
|
List<FaqCategory> defaultCategories) {
|
||||||
|
if (keepCategory == null || defaultCategories == null || defaultCategories.size() <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Date now = new Date();
|
||||||
|
BigInteger userId = getCurrentUserId();
|
||||||
|
|
||||||
|
for (FaqCategory duplicate : defaultCategories) {
|
||||||
|
if (duplicate == null || duplicate.getId() == null || keepCategory.getId().equals(duplicate.getId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryWrapper faqQuery = QueryWrapper.create()
|
||||||
|
.eq(FaqItem::getCollectionId, collectionId)
|
||||||
|
.eq(FaqItem::getCategoryId, duplicate.getId());
|
||||||
|
FaqItem faqItem = new FaqItem();
|
||||||
|
faqItem.setCategoryId(keepCategory.getId());
|
||||||
|
faqItem.setModified(now);
|
||||||
|
faqItem.setModifiedBy(userId);
|
||||||
|
faqItemMapper.updateByQuery(faqItem, faqQuery);
|
||||||
|
|
||||||
|
QueryWrapper childQuery = QueryWrapper.create()
|
||||||
|
.eq(FaqCategory::getCollectionId, collectionId)
|
||||||
|
.eq(FaqCategory::getParentId, duplicate.getId());
|
||||||
|
long childCount = mapper.selectCountByQuery(childQuery);
|
||||||
|
if (childCount <= 0) {
|
||||||
|
removeById(duplicate.getId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicate.setIsDefault(false);
|
||||||
|
if ("默认分类".equals(duplicate.getCategoryName())) {
|
||||||
|
duplicate.setCategoryName("默认分类副本-" + duplicate.getId());
|
||||||
|
}
|
||||||
|
duplicate.setModified(now);
|
||||||
|
duplicate.setModifiedBy(userId);
|
||||||
|
updateById(duplicate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public BigInteger ensureFaqItemCategory(BigInteger collectionId, BigInteger categoryId) {
|
||||||
|
checkFaqCollection(collectionId);
|
||||||
|
BigInteger defaultCategoryId = ensureDefaultCategory(collectionId);
|
||||||
|
if (categoryId == null) {
|
||||||
|
return defaultCategoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
FaqCategory category = getById(categoryId);
|
||||||
|
if (category == null || !collectionId.equals(category.getCollectionId())) {
|
||||||
|
throw new BusinessException("FAQ分类不存在或不属于当前知识库");
|
||||||
|
}
|
||||||
|
if (category.getStatus() != null && category.getStatus() != 0) {
|
||||||
|
throw new BusinessException("FAQ分类不可用");
|
||||||
|
}
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<FaqCategory> listByCollection(BigInteger collectionId, Boolean asTree) {
|
||||||
|
checkFaqCollection(collectionId);
|
||||||
|
ensureDefaultCategory(collectionId);
|
||||||
|
|
||||||
|
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||||
|
.eq(FaqCategory::getCollectionId, collectionId)
|
||||||
|
.orderBy("is_default desc, sort_no asc, id asc");
|
||||||
|
List<FaqCategory> categories = list(queryWrapper);
|
||||||
|
if (asTree == null || asTree) {
|
||||||
|
return Tree.tryToTree(categories, "id", "parentId");
|
||||||
|
}
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public boolean saveCategory(FaqCategory entity) {
|
||||||
|
if (entity == null) {
|
||||||
|
throw new BusinessException("FAQ分类不能为空");
|
||||||
|
}
|
||||||
|
if (entity.getCollectionId() == null) {
|
||||||
|
throw new BusinessException("知识库ID不能为空");
|
||||||
|
}
|
||||||
|
if (StringUtil.noText(entity.getCategoryName())) {
|
||||||
|
throw new BusinessException("分类名称不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
checkFaqCollection(entity.getCollectionId());
|
||||||
|
BigInteger parentId = normalizeParentId(entity.getParentId());
|
||||||
|
validateParentNotDefault(entity.getCollectionId(), parentId);
|
||||||
|
ParentMeta parentMeta = getParentMeta(entity.getCollectionId(), parentId, null);
|
||||||
|
|
||||||
|
Date now = new Date();
|
||||||
|
BigInteger userId = getCurrentUserId();
|
||||||
|
entity.setParentId(parentId);
|
||||||
|
entity.setAncestors(parentMeta.ancestors);
|
||||||
|
entity.setLevelNo(parentMeta.levelNo);
|
||||||
|
entity.setCategoryName(entity.getCategoryName().trim());
|
||||||
|
if (entity.getSortNo() == null) {
|
||||||
|
entity.setSortNo(nextSortNo(entity.getCollectionId(), parentId));
|
||||||
|
}
|
||||||
|
entity.setIsDefault(false);
|
||||||
|
if (entity.getStatus() == null) {
|
||||||
|
entity.setStatus(0);
|
||||||
|
}
|
||||||
|
entity.setCreated(now);
|
||||||
|
entity.setCreatedBy(userId);
|
||||||
|
entity.setModified(now);
|
||||||
|
entity.setModifiedBy(userId);
|
||||||
|
|
||||||
|
return save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public boolean updateCategory(FaqCategory entity) {
|
||||||
|
if (entity == null || entity.getId() == null) {
|
||||||
|
throw new BusinessException("FAQ分类ID不能为空");
|
||||||
|
}
|
||||||
|
if (StringUtil.noText(entity.getCategoryName())) {
|
||||||
|
throw new BusinessException("分类名称不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
FaqCategory old = getById(entity.getId());
|
||||||
|
if (old == null) {
|
||||||
|
throw new BusinessException("FAQ分类不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
checkFaqCollection(old.getCollectionId());
|
||||||
|
|
||||||
|
BigInteger parentId = normalizeParentId(entity.getParentId() == null ? old.getParentId() : entity.getParentId());
|
||||||
|
if (Boolean.TRUE.equals(old.getIsDefault()) && !ROOT_PARENT_ID.equals(parentId)) {
|
||||||
|
throw new BusinessException("默认分类不能设置父级");
|
||||||
|
}
|
||||||
|
if (!parentId.equals(normalizeParentId(old.getParentId()))) {
|
||||||
|
validateParentNotDefault(old.getCollectionId(), parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||||
|
.eq(FaqCategory::getCollectionId, old.getCollectionId())
|
||||||
|
.orderBy("sort_no asc, id asc");
|
||||||
|
List<FaqCategory> allCategories = list(queryWrapper);
|
||||||
|
Map<BigInteger, FaqCategory> categoryMap = new HashMap<>();
|
||||||
|
for (FaqCategory category : allCategories) {
|
||||||
|
categoryMap.put(category.getId(), category);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!categoryMap.containsKey(old.getId())) {
|
||||||
|
throw new BusinessException("FAQ分类不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId.equals(old.getId())) {
|
||||||
|
throw new BusinessException("父分类不能是自己");
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<BigInteger> descendants = findDescendantSet(categoryMap, old.getId());
|
||||||
|
if (descendants.contains(parentId)) {
|
||||||
|
throw new BusinessException("父分类不能是当前分类的子分类");
|
||||||
|
}
|
||||||
|
|
||||||
|
ParentMeta parentMeta = getParentMeta(old.getCollectionId(), parentId, old.getId());
|
||||||
|
|
||||||
|
Date now = new Date();
|
||||||
|
BigInteger userId = getCurrentUserId();
|
||||||
|
|
||||||
|
old.setParentId(parentId);
|
||||||
|
old.setAncestors(parentMeta.ancestors);
|
||||||
|
old.setLevelNo(parentMeta.levelNo);
|
||||||
|
old.setCategoryName(entity.getCategoryName().trim());
|
||||||
|
if (entity.getSortNo() != null) {
|
||||||
|
old.setSortNo(entity.getSortNo());
|
||||||
|
}
|
||||||
|
if (entity.getStatus() != null) {
|
||||||
|
old.setStatus(entity.getStatus());
|
||||||
|
}
|
||||||
|
old.setModified(now);
|
||||||
|
old.setModifiedBy(userId);
|
||||||
|
|
||||||
|
categoryMap.put(old.getId(), old);
|
||||||
|
Map<BigInteger, List<FaqCategory>> childrenMap = buildChildrenMap(categoryMap.values());
|
||||||
|
List<FaqCategory> changedChildren = new ArrayList<>();
|
||||||
|
refreshChildrenLevelAndAncestors(old, childrenMap, changedChildren, now, userId);
|
||||||
|
|
||||||
|
boolean updated = updateById(old);
|
||||||
|
if (!updated) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (FaqCategory child : changedChildren) {
|
||||||
|
updateById(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public boolean removeCategory(BigInteger id) {
|
||||||
|
if (id == null) {
|
||||||
|
throw new BusinessException("FAQ分类ID不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
FaqCategory old = getById(id);
|
||||||
|
if (old == null) {
|
||||||
|
throw new BusinessException("FAQ分类不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
checkFaqCollection(old.getCollectionId());
|
||||||
|
|
||||||
|
if (Boolean.TRUE.equals(old.getIsDefault())) {
|
||||||
|
throw new BusinessException("默认分类不允许删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryWrapper childQuery = QueryWrapper.create()
|
||||||
|
.eq(FaqCategory::getCollectionId, old.getCollectionId())
|
||||||
|
.eq(FaqCategory::getParentId, old.getId());
|
||||||
|
long childrenCount = mapper.selectCountByQuery(childQuery);
|
||||||
|
if (childrenCount > 0) {
|
||||||
|
throw new BusinessException("该分类下存在子分类,请先处理子分类");
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryWrapper faqQuery = QueryWrapper.create()
|
||||||
|
.eq(FaqItem::getCollectionId, old.getCollectionId())
|
||||||
|
.eq(FaqItem::getCategoryId, old.getId());
|
||||||
|
long faqCount = faqItemMapper.selectCountByQuery(faqQuery);
|
||||||
|
if (faqCount > 0) {
|
||||||
|
throw new BusinessException("该分类下存在FAQ条目,请先处理FAQ条目");
|
||||||
|
}
|
||||||
|
|
||||||
|
return removeById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BigInteger> findDescendantIds(BigInteger collectionId, BigInteger categoryId) {
|
||||||
|
if (collectionId == null || categoryId == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||||
|
.eq(FaqCategory::getCollectionId, collectionId)
|
||||||
|
.orderBy("sort_no asc, id asc");
|
||||||
|
List<FaqCategory> categories = list(queryWrapper);
|
||||||
|
Map<BigInteger, FaqCategory> categoryMap = new HashMap<>();
|
||||||
|
for (FaqCategory category : categories) {
|
||||||
|
categoryMap.put(category.getId(), category);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!categoryMap.containsKey(categoryId)) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<BigInteger> descendantSet = findDescendantSet(categoryMap, categoryId);
|
||||||
|
return new ArrayList<>(descendantSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<BigInteger, String> buildPathMap(BigInteger collectionId) {
|
||||||
|
if (collectionId == null) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||||
|
.eq(FaqCategory::getCollectionId, collectionId)
|
||||||
|
.orderBy("sort_no asc, id asc");
|
||||||
|
List<FaqCategory> categories = list(queryWrapper);
|
||||||
|
Map<BigInteger, FaqCategory> categoryMap = new HashMap<>();
|
||||||
|
for (FaqCategory category : categories) {
|
||||||
|
categoryMap.put(category.getId(), category);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<BigInteger, String> pathMap = new HashMap<>();
|
||||||
|
for (FaqCategory category : categories) {
|
||||||
|
buildPath(category.getId(), categoryMap, pathMap, new HashSet<>());
|
||||||
|
}
|
||||||
|
return pathMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void backfillNullFaqItemCategory(BigInteger collectionId, BigInteger defaultCategoryId) {
|
||||||
|
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||||
|
.eq(FaqItem::getCollectionId, collectionId)
|
||||||
|
.isNull(FaqItem::getCategoryId);
|
||||||
|
long emptyCategoryCount = faqItemMapper.selectCountByQuery(queryWrapper);
|
||||||
|
if (emptyCategoryCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
FaqItem faqItem = new FaqItem();
|
||||||
|
faqItem.setCategoryId(defaultCategoryId);
|
||||||
|
faqItem.setModified(new Date());
|
||||||
|
faqItem.setModifiedBy(getCurrentUserId());
|
||||||
|
faqItemMapper.updateByQuery(faqItem, queryWrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildPath(BigInteger categoryId,
|
||||||
|
Map<BigInteger, FaqCategory> categoryMap,
|
||||||
|
Map<BigInteger, String> pathMap,
|
||||||
|
Set<BigInteger> visiting) {
|
||||||
|
if (categoryId == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (pathMap.containsKey(categoryId)) {
|
||||||
|
return pathMap.get(categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
FaqCategory category = categoryMap.get(categoryId);
|
||||||
|
if (category == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visiting.add(categoryId)) {
|
||||||
|
throw new BusinessException("FAQ分类存在循环引用");
|
||||||
|
}
|
||||||
|
|
||||||
|
String path;
|
||||||
|
BigInteger parentId = normalizeParentId(category.getParentId());
|
||||||
|
if (ROOT_PARENT_ID.equals(parentId)) {
|
||||||
|
path = category.getCategoryName();
|
||||||
|
} else {
|
||||||
|
String parentPath = buildPath(parentId, categoryMap, pathMap, visiting);
|
||||||
|
if (StringUtil.noText(parentPath)) {
|
||||||
|
path = category.getCategoryName();
|
||||||
|
} else {
|
||||||
|
path = parentPath + " / " + category.getCategoryName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.remove(categoryId);
|
||||||
|
pathMap.put(categoryId, path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<BigInteger> findDescendantSet(Map<BigInteger, FaqCategory> categoryMap, BigInteger categoryId) {
|
||||||
|
Map<BigInteger, List<FaqCategory>> childrenMap = buildChildrenMap(categoryMap.values());
|
||||||
|
Set<BigInteger> result = new HashSet<>();
|
||||||
|
ArrayDeque<BigInteger> queue = new ArrayDeque<>();
|
||||||
|
queue.add(categoryId);
|
||||||
|
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
BigInteger currentId = queue.poll();
|
||||||
|
if (!result.add(currentId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<FaqCategory> children = childrenMap.getOrDefault(currentId, Collections.emptyList());
|
||||||
|
for (FaqCategory child : children) {
|
||||||
|
queue.offer(child.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<BigInteger, List<FaqCategory>> buildChildrenMap(Iterable<FaqCategory> categories) {
|
||||||
|
Map<BigInteger, List<FaqCategory>> childrenMap = new HashMap<>();
|
||||||
|
for (FaqCategory category : categories) {
|
||||||
|
BigInteger parentId = normalizeParentId(category.getParentId());
|
||||||
|
childrenMap.computeIfAbsent(parentId, key -> new ArrayList<>()).add(category);
|
||||||
|
}
|
||||||
|
return childrenMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshChildrenLevelAndAncestors(FaqCategory parent,
|
||||||
|
Map<BigInteger, List<FaqCategory>> childrenMap,
|
||||||
|
List<FaqCategory> changedChildren,
|
||||||
|
Date modified,
|
||||||
|
BigInteger modifiedBy) {
|
||||||
|
List<FaqCategory> children = childrenMap.getOrDefault(parent.getId(), Collections.emptyList());
|
||||||
|
for (FaqCategory child : children) {
|
||||||
|
int nextLevel = parent.getLevelNo() + 1;
|
||||||
|
if (nextLevel > 3) {
|
||||||
|
throw new BusinessException("最多支持三级分类");
|
||||||
|
}
|
||||||
|
child.setLevelNo(nextLevel);
|
||||||
|
child.setAncestors(parent.getAncestors() + "," + parent.getId());
|
||||||
|
child.setModified(modified);
|
||||||
|
child.setModifiedBy(modifiedBy);
|
||||||
|
changedChildren.add(child);
|
||||||
|
refreshChildrenLevelAndAncestors(child, childrenMap, changedChildren, modified, modifiedBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ParentMeta getParentMeta(BigInteger collectionId, BigInteger parentId, BigInteger currentId) {
|
||||||
|
BigInteger normalizedParentId = normalizeParentId(parentId);
|
||||||
|
if (ROOT_PARENT_ID.equals(normalizedParentId)) {
|
||||||
|
return new ParentMeta("0", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
FaqCategory parent = getById(normalizedParentId);
|
||||||
|
if (parent == null || !collectionId.equals(parent.getCollectionId())) {
|
||||||
|
throw new BusinessException("父分类不存在");
|
||||||
|
}
|
||||||
|
if (currentId != null && currentId.equals(parent.getId())) {
|
||||||
|
throw new BusinessException("父分类不能是自己");
|
||||||
|
}
|
||||||
|
|
||||||
|
int level = (parent.getLevelNo() == null ? 0 : parent.getLevelNo()) + 1;
|
||||||
|
if (level > 3) {
|
||||||
|
throw new BusinessException("最多支持三级分类");
|
||||||
|
}
|
||||||
|
return new ParentMeta(parent.getAncestors() + "," + parent.getId(), level);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigInteger normalizeParentId(BigInteger parentId) {
|
||||||
|
return parentId == null ? ROOT_PARENT_ID : parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer nextSortNo(BigInteger collectionId, BigInteger parentId) {
|
||||||
|
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||||
|
.eq(FaqCategory::getCollectionId, collectionId)
|
||||||
|
.eq(FaqCategory::getParentId, parentId)
|
||||||
|
.orderBy("sort_no desc, id desc");
|
||||||
|
List<FaqCategory> siblings = list(queryWrapper);
|
||||||
|
if (siblings == null || siblings.isEmpty() || siblings.get(0).getSortNo() == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return siblings.get(0).getSortNo() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateParentNotDefault(BigInteger collectionId, BigInteger parentId) {
|
||||||
|
if (parentId == null || ROOT_PARENT_ID.equals(parentId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
FaqCategory parent = getById(parentId);
|
||||||
|
if (parent == null || !collectionId.equals(parent.getCollectionId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Boolean.TRUE.equals(parent.getIsDefault())) {
|
||||||
|
throw new BusinessException("默认分类不允许创建子分类");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DocumentCollection checkFaqCollection(BigInteger collectionId) {
|
||||||
|
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类型");
|
||||||
|
}
|
||||||
|
return collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigInteger getCurrentUserId() {
|
||||||
|
if (!StpUtil.isLogin()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return BigInteger.valueOf(StpUtil.getLoginIdAsLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class ParentMeta {
|
||||||
|
private final String ancestors;
|
||||||
|
private final int levelNo;
|
||||||
|
|
||||||
|
private ParentMeta(String ancestors, int levelNo) {
|
||||||
|
this.ancestors = ancestors;
|
||||||
|
this.levelNo = levelNo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import tech.easyflow.ai.entity.DocumentCollection;
|
|||||||
import tech.easyflow.ai.entity.FaqItem;
|
import tech.easyflow.ai.entity.FaqItem;
|
||||||
import tech.easyflow.ai.entity.Model;
|
import tech.easyflow.ai.entity.Model;
|
||||||
import tech.easyflow.ai.mapper.FaqItemMapper;
|
import tech.easyflow.ai.mapper.FaqItemMapper;
|
||||||
|
import tech.easyflow.ai.service.FaqCategoryService;
|
||||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
import tech.easyflow.ai.service.FaqItemService;
|
import tech.easyflow.ai.service.FaqItemService;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
@@ -47,6 +48,9 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
@Resource
|
@Resource
|
||||||
private ModelService modelService;
|
private ModelService modelService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private FaqCategoryService faqCategoryService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private SearcherFactory searcherFactory;
|
private SearcherFactory searcherFactory;
|
||||||
|
|
||||||
@@ -54,6 +58,9 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
@Transactional
|
@Transactional
|
||||||
public boolean saveFaqItem(FaqItem entity) {
|
public boolean saveFaqItem(FaqItem entity) {
|
||||||
checkAndNormalize(entity, true);
|
checkAndNormalize(entity, true);
|
||||||
|
DocumentCollection collection = getFaqCollection(entity.getCollectionId());
|
||||||
|
entity.setCategoryId(faqCategoryService.ensureFaqItemCategory(entity.getCollectionId(), entity.getCategoryId()));
|
||||||
|
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
BigInteger userId = getCurrentUserId();
|
BigInteger userId = getCurrentUserId();
|
||||||
entity.setCreated(now);
|
entity.setCreated(now);
|
||||||
@@ -69,7 +76,6 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
DocumentCollection collection = getFaqCollection(entity.getCollectionId());
|
|
||||||
storeToVector(collection, entity, false);
|
storeToVector(collection, entity, false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -89,6 +95,8 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
}
|
}
|
||||||
checkAndNormalize(entity, false);
|
checkAndNormalize(entity, false);
|
||||||
|
|
||||||
|
DocumentCollection collection = getFaqCollection(old.getCollectionId());
|
||||||
|
old.setCategoryId(faqCategoryService.ensureFaqItemCategory(old.getCollectionId(), entity.getCategoryId()));
|
||||||
old.setQuestion(entity.getQuestion());
|
old.setQuestion(entity.getQuestion());
|
||||||
old.setAnswerHtml(entity.getAnswerHtml());
|
old.setAnswerHtml(entity.getAnswerHtml());
|
||||||
old.setAnswerText(entity.getAnswerText());
|
old.setAnswerText(entity.getAnswerText());
|
||||||
@@ -104,7 +112,6 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
DocumentCollection collection = getFaqCollection(old.getCollectionId());
|
|
||||||
storeToVector(collection, old, true);
|
storeToVector(collection, old, true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -226,6 +233,7 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
Map<String, Object> metadata = new HashMap<>();
|
Map<String, Object> metadata = new HashMap<>();
|
||||||
metadata.put("question", entity.getQuestion());
|
metadata.put("question", entity.getQuestion());
|
||||||
metadata.put("answerText", entity.getAnswerText());
|
metadata.put("answerText", entity.getAnswerText());
|
||||||
|
metadata.put("categoryId", entity.getCategoryId());
|
||||||
doc.setMetadataMap(metadata);
|
doc.setMetadataMap(metadata);
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,28 @@
|
|||||||
},
|
},
|
||||||
"faq": {
|
"faq": {
|
||||||
"faqList": "FAQ List",
|
"faqList": "FAQ List",
|
||||||
|
"allFaq": "All FAQ",
|
||||||
|
"category": "Category",
|
||||||
|
"categoryPlaceholder": "Select a category (empty means default category)",
|
||||||
|
"categoryTree": "Category Tree",
|
||||||
|
"categoryPath": "Category Path",
|
||||||
|
"categoryName": "Category Name",
|
||||||
|
"categoryNamePlaceholder": "Please input category name",
|
||||||
|
"parentCategory": "Parent Category",
|
||||||
|
"parentCategoryPlaceholder": "Please select parent category",
|
||||||
|
"rootCategory": "Root Category",
|
||||||
|
"sortNo": "Sort No",
|
||||||
|
"addCategory": "Add Category",
|
||||||
|
"addSiblingCategory": "Add Sibling Category",
|
||||||
|
"addChildCategory": "Add Child Category",
|
||||||
|
"moveUp": "Move Up",
|
||||||
|
"moveDown": "Move Down",
|
||||||
|
"promote": "Promote",
|
||||||
|
"demote": "Demote",
|
||||||
|
"editCategory": "Edit Category",
|
||||||
|
"maxLevelTip": "Maximum 3 levels are supported",
|
||||||
|
"defaultCategoryChildForbidden": "Default category cannot have child categories",
|
||||||
|
"defaultCategoryDeleteForbidden": "Default category cannot be deleted",
|
||||||
"question": "Question",
|
"question": "Question",
|
||||||
"answer": "Answer",
|
"answer": "Answer",
|
||||||
"questionPlaceholder": "Please input question",
|
"questionPlaceholder": "Please input question",
|
||||||
|
|||||||
@@ -81,6 +81,28 @@
|
|||||||
},
|
},
|
||||||
"faq": {
|
"faq": {
|
||||||
"faqList": "FAQ列表",
|
"faqList": "FAQ列表",
|
||||||
|
"allFaq": "全部FAQ",
|
||||||
|
"category": "分类",
|
||||||
|
"categoryPlaceholder": "请选择分类(不选则自动归入默认分类)",
|
||||||
|
"categoryTree": "分类树",
|
||||||
|
"categoryPath": "分类路径",
|
||||||
|
"categoryName": "分类名称",
|
||||||
|
"categoryNamePlaceholder": "请输入分类名称",
|
||||||
|
"parentCategory": "父分类",
|
||||||
|
"parentCategoryPlaceholder": "请选择父分类",
|
||||||
|
"rootCategory": "根分类",
|
||||||
|
"sortNo": "排序",
|
||||||
|
"addCategory": "新增分类",
|
||||||
|
"addSiblingCategory": "新增同级分类",
|
||||||
|
"addChildCategory": "新增子分类",
|
||||||
|
"moveUp": "上移",
|
||||||
|
"moveDown": "下移",
|
||||||
|
"promote": "升级",
|
||||||
|
"demote": "降级",
|
||||||
|
"editCategory": "编辑分类",
|
||||||
|
"maxLevelTip": "最多支持三级分类",
|
||||||
|
"defaultCategoryChildForbidden": "默认分类不允许创建子分类",
|
||||||
|
"defaultCategoryDeleteForbidden": "默认分类不允许删除",
|
||||||
"question": "问题",
|
"question": "问题",
|
||||||
"answer": "答案",
|
"answer": "答案",
|
||||||
"questionPlaceholder": "请输入问题",
|
"questionPlaceholder": "请输入问题",
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, onMounted, ref} from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import {useRoute, useRouter} from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import {$t} from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
import {ArrowLeft, Plus} from '@element-plus/icons-vue';
|
import { ArrowLeft, Plus } from '@element-plus/icons-vue';
|
||||||
import {ElIcon, ElImage} from 'element-plus';
|
import { ElIcon, ElImage } from 'element-plus';
|
||||||
|
|
||||||
import {api} from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import bookIcon from '#/assets/ai/knowledge/book.svg';
|
import bookIcon from '#/assets/ai/knowledge/book.svg';
|
||||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||||
import PageSide from '#/components/page/PageSide.vue';
|
|
||||||
import ChunkDocumentTable from '#/views/ai/documentCollection/ChunkDocumentTable.vue';
|
import ChunkDocumentTable from '#/views/ai/documentCollection/ChunkDocumentTable.vue';
|
||||||
import DocumentCollectionDataConfig
|
import DocumentCollectionDataConfig from '#/views/ai/documentCollection/DocumentCollectionDataConfig.vue';
|
||||||
from '#/views/ai/documentCollection/DocumentCollectionDataConfig.vue';
|
|
||||||
import DocumentTable from '#/views/ai/documentCollection/DocumentTable.vue';
|
import DocumentTable from '#/views/ai/documentCollection/DocumentTable.vue';
|
||||||
import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
||||||
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
||||||
@@ -27,11 +25,10 @@ const knowledgeId = ref<string>((route.query.id as string) || '');
|
|||||||
const activeMenu = ref<string>((route.query.activeMenu as string) || '');
|
const activeMenu = ref<string>((route.query.activeMenu as string) || '');
|
||||||
const knowledgeInfo = ref<any>({});
|
const knowledgeInfo = ref<any>({});
|
||||||
const selectedCategory = ref('');
|
const selectedCategory = ref('');
|
||||||
const defaultSelectedMenu = ref('');
|
|
||||||
|
|
||||||
const resolveDefaultMenu = (collectionType: string, menuKey: string) => {
|
const resolveDefaultMenu = (collectionType: string, menuKey: string) => {
|
||||||
const faqMenus = new Set(['faqList', 'knowledgeSearch', 'config']);
|
const faqMenus = new Set(['config', 'faqList', 'knowledgeSearch']);
|
||||||
const documentMenus = new Set(['documentList', 'knowledgeSearch', 'config']);
|
const documentMenus = new Set(['config', 'documentList', 'knowledgeSearch']);
|
||||||
const fallbackMenu = collectionType === 'FAQ' ? 'faqList' : 'documentList';
|
const fallbackMenu = collectionType === 'FAQ' ? 'faqList' : 'documentList';
|
||||||
|
|
||||||
if (!menuKey) {
|
if (!menuKey) {
|
||||||
@@ -53,7 +50,6 @@ const getKnowledge = () => {
|
|||||||
res.data.collectionType || 'DOCUMENT',
|
res.data.collectionType || 'DOCUMENT',
|
||||||
activeMenu.value,
|
activeMenu.value,
|
||||||
);
|
);
|
||||||
defaultSelectedMenu.value = initialMenu;
|
|
||||||
selectedCategory.value = initialMenu;
|
selectedCategory.value = initialMenu;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -64,18 +60,26 @@ onMounted(() => {
|
|||||||
const back = () => {
|
const back = () => {
|
||||||
router.push({ path: '/ai/documentCollection' });
|
router.push({ path: '/ai/documentCollection' });
|
||||||
};
|
};
|
||||||
const isFaqCollection = computed(() => knowledgeInfo.value.collectionType === 'FAQ');
|
const isFaqCollection = computed(
|
||||||
|
() => knowledgeInfo.value.collectionType === 'FAQ',
|
||||||
|
);
|
||||||
const categoryData = computed(() => {
|
const categoryData = computed(() => {
|
||||||
if (isFaqCollection.value) {
|
if (isFaqCollection.value) {
|
||||||
return [
|
return [
|
||||||
{ key: 'faqList', name: $t('documentCollection.faq.faqList') },
|
{ key: 'faqList', name: $t('documentCollection.faq.faqList') },
|
||||||
{ key: 'knowledgeSearch', name: $t('documentCollection.knowledgeRetrieval') },
|
{
|
||||||
|
key: 'knowledgeSearch',
|
||||||
|
name: $t('documentCollection.knowledgeRetrieval'),
|
||||||
|
},
|
||||||
{ key: 'config', name: $t('documentCollection.config') },
|
{ key: 'config', name: $t('documentCollection.config') },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ key: 'documentList', name: $t('documentCollection.documentList') },
|
{ key: 'documentList', name: $t('documentCollection.documentList') },
|
||||||
{ key: 'knowledgeSearch', name: $t('documentCollection.knowledgeRetrieval') },
|
{
|
||||||
|
key: 'knowledgeSearch',
|
||||||
|
name: $t('documentCollection.knowledgeRetrieval'),
|
||||||
|
},
|
||||||
{ key: 'config', name: $t('documentCollection.config') },
|
{ key: 'config', name: $t('documentCollection.config') },
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -106,8 +110,8 @@ const handleButtonClick = (event: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleCategoryClick = (category: any) => {
|
const handleCategoryClick = (menuKey: string) => {
|
||||||
selectedCategory.value = category.key;
|
selectedCategory.value = menuKey;
|
||||||
viewDocVisible.value = false;
|
viewDocVisible.value = false;
|
||||||
};
|
};
|
||||||
const viewDocVisible = ref(false);
|
const viewDocVisible = ref(false);
|
||||||
@@ -138,17 +142,19 @@ const backDoc = () => {
|
|||||||
{{ knowledgeInfo.description || '' }}
|
{{ knowledgeInfo.description || '' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="doc-top-menu">
|
||||||
|
<button
|
||||||
|
v-for="item in categoryData"
|
||||||
|
:key="item.key"
|
||||||
|
class="doc-menu-item"
|
||||||
|
:class="{ active: selectedCategory === item.key }"
|
||||||
|
@click="handleCategoryClick(item.key)"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="doc-content">
|
<div class="doc-content">
|
||||||
<div>
|
|
||||||
<PageSide
|
|
||||||
label-key="name"
|
|
||||||
value-key="key"
|
|
||||||
:menus="categoryData"
|
|
||||||
:default-selected="defaultSelectedMenu"
|
|
||||||
@change="handleCategoryClick"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="doc-table-content menu-container border border-[var(--el-border-color)]"
|
class="doc-table-content menu-container border border-[var(--el-border-color)]"
|
||||||
>
|
>
|
||||||
@@ -230,11 +236,53 @@ const backDoc = () => {
|
|||||||
}
|
}
|
||||||
.doc-content {
|
.doc-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.doc-top-menu {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 32px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.doc-menu-item {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s,
|
||||||
|
color 0.2s,
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.doc-menu-item:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
.doc-menu-item.active {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
border-color: var(--el-color-primary-light-7);
|
||||||
|
box-shadow: 0 2px 10px rgb(64 158 255 / 16%);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.doc-menu-item:focus-visible {
|
||||||
|
outline: 2px solid var(--el-color-primary-light-5);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.doc-table {
|
.doc-table {
|
||||||
background-color: var(--el-bg-color);
|
background-color: var(--el-bg-color);
|
||||||
@@ -260,6 +308,7 @@ const backDoc = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
min-width: 160px;
|
||||||
}
|
}
|
||||||
.title {
|
.title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElDialog,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElTreeSelect,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Object as any,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
parentOptions: {
|
||||||
|
type: Array as any,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
disableParent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'submit']);
|
||||||
|
|
||||||
|
const formRef = ref();
|
||||||
|
const form = ref<any>({
|
||||||
|
id: '',
|
||||||
|
collectionId: '',
|
||||||
|
categoryName: '',
|
||||||
|
parentId: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.data,
|
||||||
|
(newData: any) => {
|
||||||
|
form.value = {
|
||||||
|
id: newData?.id || '',
|
||||||
|
collectionId: newData?.collectionId || '',
|
||||||
|
categoryName: newData?.categoryName || '',
|
||||||
|
parentId:
|
||||||
|
newData?.parentId === undefined || newData?.parentId === null
|
||||||
|
? '0'
|
||||||
|
: String(newData.parentId),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const rules = computed(() => ({
|
||||||
|
categoryName: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: $t('message.required'),
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parentId: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: $t('message.required'),
|
||||||
|
trigger: 'change',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
emit('update:modelValue', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
formRef.value?.validate((valid: boolean) => {
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('submit', {
|
||||||
|
...form.value,
|
||||||
|
categoryName: form.value.categoryName.trim(),
|
||||||
|
parentId: String(form.value.parentId || '0'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
:title="title"
|
||||||
|
width="520px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@close="closeDialog"
|
||||||
|
>
|
||||||
|
<ElForm ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||||
|
<ElFormItem
|
||||||
|
:label="$t('documentCollection.faq.categoryName')"
|
||||||
|
prop="categoryName"
|
||||||
|
>
|
||||||
|
<ElInput
|
||||||
|
v-model="form.categoryName"
|
||||||
|
:placeholder="$t('documentCollection.faq.categoryNamePlaceholder')"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem
|
||||||
|
:label="$t('documentCollection.faq.parentCategory')"
|
||||||
|
prop="parentId"
|
||||||
|
>
|
||||||
|
<ElTreeSelect
|
||||||
|
v-model="form.parentId"
|
||||||
|
:disabled="disableParent"
|
||||||
|
check-strictly
|
||||||
|
clearable
|
||||||
|
:data="parentOptions"
|
||||||
|
node-key="id"
|
||||||
|
:props="{ label: 'categoryName', children: 'children' }"
|
||||||
|
:placeholder="$t('documentCollection.faq.parentCategoryPlaceholder')"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="closeDialog">{{ $t('button.cancel') }}</ElButton>
|
||||||
|
<ElButton type="primary" @click="handleSubmit">
|
||||||
|
{{ $t('button.save') }}
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {IDomEditor} from '@wangeditor/editor';
|
import type { IDomEditor } from '@wangeditor/editor';
|
||||||
|
|
||||||
import {onBeforeUnmount, ref, shallowRef, watch} from 'vue';
|
import { nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
|
||||||
|
|
||||||
import {$t} from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
import {ElButton, ElDialog, ElForm, ElFormItem, ElInput, ElMessage} from 'element-plus';
|
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import {Editor, Toolbar} from '@wangeditor/editor-for-vue';
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElDialog,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElMessage,
|
||||||
|
ElTreeSelect,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
import '@wangeditor/editor/dist/css/style.css';
|
import '@wangeditor/editor/dist/css/style.css';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -19,6 +28,10 @@ const props = defineProps({
|
|||||||
type: Object as any,
|
type: Object as any,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
|
categoryOptions: {
|
||||||
|
type: Array as any,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'update:modelValue']);
|
const emit = defineEmits(['submit', 'update:modelValue']);
|
||||||
@@ -27,6 +40,7 @@ const editorRef = shallowRef<IDomEditor | null>(null);
|
|||||||
const form = ref<any>({
|
const form = ref<any>({
|
||||||
id: '',
|
id: '',
|
||||||
collectionId: '',
|
collectionId: '',
|
||||||
|
categoryId: '',
|
||||||
question: '',
|
question: '',
|
||||||
answerHtml: '',
|
answerHtml: '',
|
||||||
orderNo: 0,
|
orderNo: 0,
|
||||||
@@ -38,6 +52,10 @@ watch(
|
|||||||
form.value = {
|
form.value = {
|
||||||
id: newData?.id || '',
|
id: newData?.id || '',
|
||||||
collectionId: newData?.collectionId || '',
|
collectionId: newData?.collectionId || '',
|
||||||
|
categoryId:
|
||||||
|
newData?.categoryId === undefined || newData?.categoryId === null
|
||||||
|
? ''
|
||||||
|
: String(newData.categoryId),
|
||||||
question: newData?.question || '',
|
question: newData?.question || '',
|
||||||
answerHtml: newData?.answerHtml || '',
|
answerHtml: newData?.answerHtml || '',
|
||||||
orderNo: newData?.orderNo ?? 0,
|
orderNo: newData?.orderNo ?? 0,
|
||||||
@@ -66,6 +84,30 @@ const handleEditorCreated = (editor: IDomEditor) => {
|
|||||||
editorRef.value = editor;
|
editorRef.value = editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const focusAnswerEditor = () => {
|
||||||
|
const editor = editorRef.value as any;
|
||||||
|
if (editor && typeof editor.focus === 'function') {
|
||||||
|
try {
|
||||||
|
editor.focus(true);
|
||||||
|
} catch {
|
||||||
|
editor.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
const editableEl = document.querySelector(
|
||||||
|
'.faq-edit-dialog .w-e-text-container [contenteditable="true"]',
|
||||||
|
) as HTMLElement | null;
|
||||||
|
editableEl?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuestionKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Tab' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
focusAnswerEditor();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
emit('update:modelValue', false);
|
emit('update:modelValue', false);
|
||||||
};
|
};
|
||||||
@@ -76,7 +118,7 @@ const handleSubmit = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sanitizedHtml = DOMPurify.sanitize(form.value.answerHtml || '');
|
const sanitizedHtml = DOMPurify.sanitize(form.value.answerHtml || '');
|
||||||
const pureText = sanitizedHtml.replace(/<[^>]*>/g, '').trim();
|
const pureText = sanitizedHtml.replaceAll(/<[^>]*>/g, '').trim();
|
||||||
if (!pureText) {
|
if (!pureText) {
|
||||||
ElMessage.error($t('documentCollection.faq.answerRequired'));
|
ElMessage.error($t('documentCollection.faq.answerRequired'));
|
||||||
return;
|
return;
|
||||||
@@ -86,6 +128,7 @@ const handleSubmit = () => {
|
|||||||
question: form.value.question.trim(),
|
question: form.value.question.trim(),
|
||||||
answerHtml: sanitizedHtml,
|
answerHtml: sanitizedHtml,
|
||||||
orderNo: Number(form.value.orderNo) || 0,
|
orderNo: Number(form.value.orderNo) || 0,
|
||||||
|
categoryId: form.value.categoryId || null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,11 +150,24 @@ onBeforeUnmount(() => {
|
|||||||
@close="closeDialog"
|
@close="closeDialog"
|
||||||
>
|
>
|
||||||
<ElForm class="faq-form" label-position="top">
|
<ElForm class="faq-form" label-position="top">
|
||||||
|
<ElFormItem :label="$t('documentCollection.faq.category')">
|
||||||
|
<ElTreeSelect
|
||||||
|
v-model="form.categoryId"
|
||||||
|
check-strictly
|
||||||
|
clearable
|
||||||
|
:data="categoryOptions"
|
||||||
|
node-key="id"
|
||||||
|
:props="{ label: 'categoryName', children: 'children' }"
|
||||||
|
:placeholder="$t('documentCollection.faq.categoryPlaceholder')"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
<ElFormItem :label="$t('documentCollection.faq.question')">
|
<ElFormItem :label="$t('documentCollection.faq.question')">
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model="form.question"
|
v-model="form.question"
|
||||||
:placeholder="$t('documentCollection.faq.questionPlaceholder')"
|
:placeholder="$t('documentCollection.faq.questionPlaceholder')"
|
||||||
|
@keydown="handleQuestionKeydown"
|
||||||
/>
|
/>
|
||||||
|
<div class="field-tip">Tab 可快速跳转到答案编辑区域</div>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem :label="$t('documentCollection.faq.answer')">
|
<ElFormItem :label="$t('documentCollection.faq.answer')">
|
||||||
<div class="editor-wrapper">
|
<div class="editor-wrapper">
|
||||||
@@ -131,7 +187,9 @@ onBeforeUnmount(() => {
|
|||||||
</ElForm>
|
</ElForm>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<ElButton class="footer-btn" @click="closeDialog">{{ $t('button.cancel') }}</ElButton>
|
<ElButton class="footer-btn" @click="closeDialog">
|
||||||
|
{{ $t('button.cancel') }}
|
||||||
|
</ElButton>
|
||||||
<ElButton class="footer-btn" type="primary" @click="handleSubmit">
|
<ElButton class="footer-btn" type="primary" @click="handleSubmit">
|
||||||
{{ $t('button.save') }}
|
{{ $t('button.save') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -146,27 +204,69 @@ onBeforeUnmount(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.faq-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-form :deep(.el-input__wrapper),
|
||||||
|
.faq-form :deep(.el-select__wrapper) {
|
||||||
|
border-radius: 10px;
|
||||||
|
min-height: 42px;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-form :deep(.el-input__wrapper.is-focus),
|
||||||
|
.faq-form :deep(.el-select__wrapper.is-focused) {
|
||||||
|
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-tip {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-wrapper {
|
.editor-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--el-border-color-light);
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
background: var(--el-fill-color-blank);
|
background: var(--el-fill-color-blank);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: border-color 0.2s ease;
|
box-shadow: 0 8px 24px rgb(15 23 42 / 4%);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-wrapper:focus-within {
|
.editor-wrapper:focus-within {
|
||||||
border-color: var(--el-color-primary);
|
border-color: var(--el-color-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgb(64 158 255 / 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.w-e-toolbar) {
|
:deep(.w-e-toolbar) {
|
||||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
border-bottom: 1px solid var(--el-border-color-light);
|
||||||
background: var(--el-fill-color-blank);
|
background: var(--el-fill-color-blank);
|
||||||
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.w-e-text-container) {
|
:deep(.w-e-text-container) {
|
||||||
min-height: 320px;
|
height: 340px;
|
||||||
|
min-height: 340px;
|
||||||
background: var(--el-fill-color-blank);
|
background: var(--el-fill-color-blank);
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.w-e-text-container .w-e-scroll) {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.w-e-text-container [data-slate-editor]) {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.w-e-text-container [data-slate-editor] p) {
|
||||||
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-footer {
|
.dialog-footer {
|
||||||
|
|||||||
@@ -1,15 +1,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref} from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import {$t} from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
import {Delete, Edit, Plus} from '@element-plus/icons-vue';
|
import {
|
||||||
import {ElButton, ElMessage, ElMessageBox, ElTable, ElTableColumn,} from 'element-plus';
|
Bottom,
|
||||||
|
CirclePlus,
|
||||||
|
Delete,
|
||||||
|
Download,
|
||||||
|
Edit,
|
||||||
|
FolderAdd,
|
||||||
|
MoreFilled,
|
||||||
|
Plus,
|
||||||
|
Top,
|
||||||
|
Upload,
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElDropdown,
|
||||||
|
ElDropdownItem,
|
||||||
|
ElDropdownMenu,
|
||||||
|
ElMessage,
|
||||||
|
ElMessageBox,
|
||||||
|
ElTable,
|
||||||
|
ElTableColumn,
|
||||||
|
ElTree,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
import {api} from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
|
|
||||||
|
import FaqCategoryDialog from './FaqCategoryDialog.vue';
|
||||||
import FaqEditDialog from './FaqEditDialog.vue';
|
import FaqEditDialog from './FaqEditDialog.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -21,10 +43,21 @@ const props = defineProps({
|
|||||||
|
|
||||||
const pageDataRef = ref();
|
const pageDataRef = ref();
|
||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
|
const categoryDialogVisible = ref(false);
|
||||||
const editData = ref<any>({});
|
const editData = ref<any>({});
|
||||||
const queryParams = ref({
|
const categoryEditData = ref<any>({});
|
||||||
|
const categoryDialogTitle = ref('');
|
||||||
|
const categoryDialogDisableParent = ref(false);
|
||||||
|
const selectedCategoryId = ref<string>('all');
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const categoryTree = ref<any[]>([]);
|
||||||
|
const categoryParentOptions = ref<any[]>([]);
|
||||||
|
const categoryActionLoading = ref(false);
|
||||||
|
|
||||||
|
const baseQueryParams = ref({
|
||||||
collectionId: props.knowledgeId,
|
collectionId: props.knowledgeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const headerButtons = [
|
const headerButtons = [
|
||||||
{
|
{
|
||||||
key: 'add',
|
key: 'add',
|
||||||
@@ -34,20 +67,83 @@ const headerButtons = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const reloadList = () => {
|
const treeData = computed(() => [
|
||||||
pageDataRef.value.setQuery(queryParams.value);
|
{
|
||||||
|
id: 'all',
|
||||||
|
categoryName: $t('documentCollection.faq.allFaq'),
|
||||||
|
isVirtual: true,
|
||||||
|
levelNo: 0,
|
||||||
|
children: categoryTree.value,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const refreshList = () => {
|
||||||
|
const query: Record<string, any> = {};
|
||||||
|
if (searchKeyword.value.trim()) {
|
||||||
|
query.question = searchKeyword.value.trim();
|
||||||
|
}
|
||||||
|
if (selectedCategoryId.value !== 'all') {
|
||||||
|
query.categoryId = selectedCategoryId.value;
|
||||||
|
}
|
||||||
|
pageDataRef.value?.setQuery(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadCategoryTree = async () => {
|
||||||
|
const res = await api.get('/api/v1/faqCategory/list', {
|
||||||
|
params: {
|
||||||
|
collectionId: props.knowledgeId,
|
||||||
|
asTree: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
categoryTree.value = normalizeCategoryTree(res.data || []);
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedCategoryId.value !== 'all' &&
|
||||||
|
!hasCategoryId(categoryTree.value, selectedCategoryId.value)
|
||||||
|
) {
|
||||||
|
selectedCategoryId.value = 'all';
|
||||||
|
}
|
||||||
|
refreshList();
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || $t('message.getDataError'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCategoryTree = (nodes: any[]): any[] => {
|
||||||
|
return (nodes || []).map((node) => ({
|
||||||
|
...node,
|
||||||
|
id: String(node.id),
|
||||||
|
parentId:
|
||||||
|
node.parentId === undefined || node.parentId === null
|
||||||
|
? '0'
|
||||||
|
: String(node.parentId),
|
||||||
|
children: normalizeCategoryTree(node.children || []),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasCategoryId = (nodes: any[], id: string): boolean => {
|
||||||
|
for (const node of nodes || []) {
|
||||||
|
if (String(node.id) === id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (node.children?.length && hasCategoryId(node.children, id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = (keyword: string) => {
|
const handleSearch = (keyword: string) => {
|
||||||
pageDataRef.value.setQuery({
|
searchKeyword.value = keyword || '';
|
||||||
...queryParams.value,
|
refreshList();
|
||||||
question: keyword,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openAddDialog = () => {
|
const openAddDialog = () => {
|
||||||
editData.value = {
|
editData.value = {
|
||||||
collectionId: props.knowledgeId,
|
collectionId: props.knowledgeId,
|
||||||
|
categoryId:
|
||||||
|
selectedCategoryId.value === 'all' ? null : selectedCategoryId.value,
|
||||||
answerHtml: '',
|
answerHtml: '',
|
||||||
question: '',
|
question: '',
|
||||||
};
|
};
|
||||||
@@ -64,6 +160,10 @@ const openEditDialog = (row: any) => {
|
|||||||
editData.value = {
|
editData.value = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
collectionId: row.collectionId,
|
collectionId: row.collectionId,
|
||||||
|
categoryId:
|
||||||
|
row.categoryId === undefined || row.categoryId === null
|
||||||
|
? ''
|
||||||
|
: String(row.categoryId),
|
||||||
question: row.question,
|
question: row.question,
|
||||||
answerHtml: row.answerHtml,
|
answerHtml: row.answerHtml,
|
||||||
orderNo: row.orderNo,
|
orderNo: row.orderNo,
|
||||||
@@ -75,9 +175,11 @@ const saveFaq = async (payload: any) => {
|
|||||||
const url = payload.id ? '/api/v1/faqItem/update' : '/api/v1/faqItem/save';
|
const url = payload.id ? '/api/v1/faqItem/update' : '/api/v1/faqItem/save';
|
||||||
const res = await api.post(url, payload);
|
const res = await api.post(url, payload);
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success(payload.id ? $t('message.updateOkMessage') : $t('message.saveOkMessage'));
|
ElMessage.success(
|
||||||
|
payload.id ? $t('message.updateOkMessage') : $t('message.saveOkMessage'),
|
||||||
|
);
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
reloadList();
|
refreshList();
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res.message);
|
ElMessage.error(res.message);
|
||||||
}
|
}
|
||||||
@@ -92,79 +194,739 @@ const removeFaq = (row: any) => {
|
|||||||
api.post('/api/v1/faqItem/remove', { id: row.id }).then((res) => {
|
api.post('/api/v1/faqItem/remove', { id: row.id }).then((res) => {
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success($t('message.deleteOkMessage'));
|
ElMessage.success($t('message.deleteOkMessage'));
|
||||||
reloadList();
|
refreshList();
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res.message);
|
ElMessage.error(res.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCategoryClick = (data: any) => {
|
||||||
|
selectedCategoryId.value = String(data.id);
|
||||||
|
refreshList();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddRootCategory = () => {
|
||||||
|
categoryDialogTitle.value = $t('documentCollection.faq.addCategory');
|
||||||
|
categoryDialogDisableParent.value = false;
|
||||||
|
categoryEditData.value = {
|
||||||
|
collectionId: props.knowledgeId,
|
||||||
|
parentId: '0',
|
||||||
|
categoryName: '',
|
||||||
|
};
|
||||||
|
categoryParentOptions.value = buildParentOptions();
|
||||||
|
categoryDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddSiblingCategory = (node: any) => {
|
||||||
|
categoryDialogTitle.value = $t('documentCollection.faq.addSiblingCategory');
|
||||||
|
categoryDialogDisableParent.value = false;
|
||||||
|
categoryEditData.value = {
|
||||||
|
collectionId: props.knowledgeId,
|
||||||
|
parentId:
|
||||||
|
node.parentId === undefined || node.parentId === null
|
||||||
|
? '0'
|
||||||
|
: String(node.parentId),
|
||||||
|
categoryName: '',
|
||||||
|
};
|
||||||
|
categoryParentOptions.value = buildParentOptions();
|
||||||
|
categoryDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddChildCategory = (node: any) => {
|
||||||
|
if (node.isDefault) {
|
||||||
|
ElMessage.warning(
|
||||||
|
$t('documentCollection.faq.defaultCategoryChildForbidden'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number(node.levelNo) >= 3) {
|
||||||
|
ElMessage.warning($t('documentCollection.faq.maxLevelTip'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
categoryDialogTitle.value = $t('documentCollection.faq.addChildCategory');
|
||||||
|
categoryDialogDisableParent.value = false;
|
||||||
|
categoryEditData.value = {
|
||||||
|
collectionId: props.knowledgeId,
|
||||||
|
parentId: String(node.id),
|
||||||
|
categoryName: '',
|
||||||
|
};
|
||||||
|
categoryParentOptions.value = buildParentOptions();
|
||||||
|
categoryDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditCategory = (node: any) => {
|
||||||
|
categoryDialogTitle.value = $t('documentCollection.faq.editCategory');
|
||||||
|
categoryDialogDisableParent.value = !!node.isDefault;
|
||||||
|
categoryEditData.value = {
|
||||||
|
id: node.id,
|
||||||
|
collectionId: node.collectionId,
|
||||||
|
parentId:
|
||||||
|
node.parentId === undefined || node.parentId === null
|
||||||
|
? '0'
|
||||||
|
: String(node.parentId),
|
||||||
|
categoryName: node.categoryName,
|
||||||
|
};
|
||||||
|
categoryParentOptions.value = buildParentOptions(String(node.id));
|
||||||
|
categoryDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCategory = (node: any) => {
|
||||||
|
if (node.isDefault) {
|
||||||
|
ElMessage.warning(
|
||||||
|
$t('documentCollection.faq.defaultCategoryDeleteForbidden'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||||
|
confirmButtonText: $t('button.confirm'),
|
||||||
|
cancelButtonText: $t('button.cancel'),
|
||||||
|
type: 'warning',
|
||||||
|
}).then(async () => {
|
||||||
|
const res = await api.post('/api/v1/faqCategory/remove', { id: node.id });
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
ElMessage.success($t('message.deleteOkMessage'));
|
||||||
|
if (selectedCategoryId.value === String(node.id)) {
|
||||||
|
selectedCategoryId.value = 'all';
|
||||||
|
}
|
||||||
|
await reloadCategoryTree();
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toLevel = (value: any, fallback = 1) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findNodeById = (
|
||||||
|
targetId: string,
|
||||||
|
nodes: any[] = categoryTree.value,
|
||||||
|
): any => {
|
||||||
|
for (const node of nodes || []) {
|
||||||
|
if (String(node.id) === targetId) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
if (node.children?.length) {
|
||||||
|
const found = findNodeById(targetId, node.children);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findNodeContext = (
|
||||||
|
targetId: string,
|
||||||
|
nodes: any[] = categoryTree.value,
|
||||||
|
parent: any = null,
|
||||||
|
): any => {
|
||||||
|
for (let i = 0; i < (nodes || []).length; i += 1) {
|
||||||
|
const node = nodes[i];
|
||||||
|
if (String(node.id) === targetId) {
|
||||||
|
return {
|
||||||
|
node,
|
||||||
|
parent,
|
||||||
|
siblings: nodes,
|
||||||
|
index: i,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (node.children?.length) {
|
||||||
|
const found = findNodeContext(targetId, node.children, node);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findSubtreeMaxLevel = (node: any): number => {
|
||||||
|
const current = toLevel(node?.levelNo, 1);
|
||||||
|
let maxLevel = current;
|
||||||
|
for (const child of node?.children || []) {
|
||||||
|
maxLevel = Math.max(maxLevel, findSubtreeMaxLevel(child));
|
||||||
|
}
|
||||||
|
return maxLevel;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canMoveUnderParent = (node: any, newParentLevel: number): boolean => {
|
||||||
|
const currentLevel = toLevel(node?.levelNo, 1);
|
||||||
|
const subtreeMaxLevel = findSubtreeMaxLevel(node);
|
||||||
|
const targetLevel = newParentLevel + 1;
|
||||||
|
const delta = targetLevel - currentLevel;
|
||||||
|
return subtreeMaxLevel + delta <= 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChildrenByParentId = (parentId: string): any[] => {
|
||||||
|
if (parentId === '0') {
|
||||||
|
return categoryTree.value || [];
|
||||||
|
}
|
||||||
|
const parent = findNodeById(parentId);
|
||||||
|
return parent?.children || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCategoryRequest = async (payload: Record<string, any>) => {
|
||||||
|
const res = await api.post('/api/v1/faqCategory/update', payload);
|
||||||
|
if (res.errorCode !== 0) {
|
||||||
|
throw new Error(res.message || $t('message.getDataError'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistSiblingOrder = async (siblings: any[]) => {
|
||||||
|
for (const [i, item] of siblings.entries()) {
|
||||||
|
await updateCategoryRequest({
|
||||||
|
id: item.id,
|
||||||
|
collectionId: item.collectionId,
|
||||||
|
parentId:
|
||||||
|
item.parentId === undefined || item.parentId === null
|
||||||
|
? '0'
|
||||||
|
: String(item.parentId),
|
||||||
|
categoryName: item.categoryName,
|
||||||
|
sortNo: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canMoveUp = (node: any): boolean => {
|
||||||
|
const ctx = findNodeContext(String(node.id));
|
||||||
|
if (!ctx || ctx.index <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const previousSibling = ctx.siblings[ctx.index - 1];
|
||||||
|
return !previousSibling?.isDefault;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canMoveDown = (node: any): boolean => {
|
||||||
|
const ctx = findNodeContext(String(node.id));
|
||||||
|
return !!ctx && ctx.index < ctx.siblings.length - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canPromote = (node: any): boolean => {
|
||||||
|
const ctx = findNodeContext(String(node.id));
|
||||||
|
if (!ctx || !ctx.parent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const parentParentId =
|
||||||
|
ctx.parent.parentId === undefined || ctx.parent.parentId === null
|
||||||
|
? '0'
|
||||||
|
: String(ctx.parent.parentId);
|
||||||
|
if (parentParentId === String(ctx.parent.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const newParentLevel =
|
||||||
|
parentParentId === '0'
|
||||||
|
? 0
|
||||||
|
: toLevel(findNodeById(parentParentId)?.levelNo, 1);
|
||||||
|
return canMoveUnderParent(node, newParentLevel);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canDemote = (node: any): boolean => {
|
||||||
|
const ctx = findNodeContext(String(node.id));
|
||||||
|
if (!ctx || ctx.index <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const previousSibling = ctx.siblings[ctx.index - 1];
|
||||||
|
if (previousSibling?.isDefault) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return canMoveUnderParent(node, toLevel(previousSibling?.levelNo, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveCategoryUp = async (node: any) => {
|
||||||
|
if (categoryActionLoading.value || !canMoveUp(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = findNodeContext(String(node.id));
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reordered = [...ctx.siblings];
|
||||||
|
[reordered[ctx.index - 1], reordered[ctx.index]] = [
|
||||||
|
reordered[ctx.index],
|
||||||
|
reordered[ctx.index - 1],
|
||||||
|
];
|
||||||
|
categoryActionLoading.value = true;
|
||||||
|
try {
|
||||||
|
await persistSiblingOrder(reordered);
|
||||||
|
ElMessage.success($t('message.updateOkMessage'));
|
||||||
|
await reloadCategoryTree();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || $t('message.getDataError'));
|
||||||
|
} finally {
|
||||||
|
categoryActionLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveCategoryDown = async (node: any) => {
|
||||||
|
if (categoryActionLoading.value || !canMoveDown(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = findNodeContext(String(node.id));
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reordered = [...ctx.siblings];
|
||||||
|
[reordered[ctx.index], reordered[ctx.index + 1]] = [
|
||||||
|
reordered[ctx.index + 1],
|
||||||
|
reordered[ctx.index],
|
||||||
|
];
|
||||||
|
categoryActionLoading.value = true;
|
||||||
|
try {
|
||||||
|
await persistSiblingOrder(reordered);
|
||||||
|
ElMessage.success($t('message.updateOkMessage'));
|
||||||
|
await reloadCategoryTree();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || $t('message.getDataError'));
|
||||||
|
} finally {
|
||||||
|
categoryActionLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const promoteCategory = async (node: any) => {
|
||||||
|
if (categoryActionLoading.value || !canPromote(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = findNodeContext(String(node.id));
|
||||||
|
if (!ctx || !ctx.parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newParentId =
|
||||||
|
ctx.parent.parentId === undefined || ctx.parent.parentId === null
|
||||||
|
? '0'
|
||||||
|
: String(ctx.parent.parentId);
|
||||||
|
const newSiblings = getChildrenByParentId(newParentId);
|
||||||
|
const oldSiblingsWithoutNode = ctx.siblings.filter(
|
||||||
|
(_: any, index: number) => index !== ctx.index,
|
||||||
|
);
|
||||||
|
categoryActionLoading.value = true;
|
||||||
|
try {
|
||||||
|
await updateCategoryRequest({
|
||||||
|
id: node.id,
|
||||||
|
collectionId: node.collectionId,
|
||||||
|
parentId: newParentId,
|
||||||
|
categoryName: node.categoryName,
|
||||||
|
sortNo: newSiblings.length,
|
||||||
|
});
|
||||||
|
await persistSiblingOrder(oldSiblingsWithoutNode);
|
||||||
|
ElMessage.success($t('message.updateOkMessage'));
|
||||||
|
await reloadCategoryTree();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || $t('message.getDataError'));
|
||||||
|
} finally {
|
||||||
|
categoryActionLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const demoteCategory = async (node: any) => {
|
||||||
|
if (categoryActionLoading.value || !canDemote(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = findNodeContext(String(node.id));
|
||||||
|
if (!ctx || ctx.index <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previousSibling = ctx.siblings[ctx.index - 1];
|
||||||
|
if (previousSibling?.isDefault) {
|
||||||
|
ElMessage.warning(
|
||||||
|
$t('documentCollection.faq.defaultCategoryChildForbidden'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const childCategories = previousSibling.children || [];
|
||||||
|
const oldSiblingsWithoutNode = ctx.siblings.filter(
|
||||||
|
(_: any, index: number) => index !== ctx.index,
|
||||||
|
);
|
||||||
|
categoryActionLoading.value = true;
|
||||||
|
try {
|
||||||
|
await updateCategoryRequest({
|
||||||
|
id: node.id,
|
||||||
|
collectionId: node.collectionId,
|
||||||
|
parentId: String(previousSibling.id),
|
||||||
|
categoryName: node.categoryName,
|
||||||
|
sortNo: childCategories.length,
|
||||||
|
});
|
||||||
|
await persistSiblingOrder(oldSiblingsWithoutNode);
|
||||||
|
ElMessage.success($t('message.updateOkMessage'));
|
||||||
|
await reloadCategoryTree();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.message || $t('message.getDataError'));
|
||||||
|
} finally {
|
||||||
|
categoryActionLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCategory = async (payload: any) => {
|
||||||
|
const url = payload.id
|
||||||
|
? '/api/v1/faqCategory/update'
|
||||||
|
: '/api/v1/faqCategory/save';
|
||||||
|
const res = await api.post(url, payload);
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
ElMessage.success(
|
||||||
|
payload.id ? $t('message.updateOkMessage') : $t('message.saveOkMessage'),
|
||||||
|
);
|
||||||
|
categoryDialogVisible.value = false;
|
||||||
|
await reloadCategoryTree();
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildParentOptions = (excludeRootId?: string) => {
|
||||||
|
const excludedIds = new Set<string>();
|
||||||
|
if (excludeRootId) {
|
||||||
|
collectDescendantIds(categoryTree.value, excludeRootId, excludedIds);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: '0',
|
||||||
|
categoryName: $t('documentCollection.faq.rootCategory'),
|
||||||
|
children: filterTree(categoryTree.value, excludedIds),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectDescendantIds = (
|
||||||
|
nodes: any[],
|
||||||
|
rootId: string,
|
||||||
|
output: Set<string>,
|
||||||
|
) => {
|
||||||
|
for (const node of nodes || []) {
|
||||||
|
const currentId = String(node.id);
|
||||||
|
if (currentId === rootId) {
|
||||||
|
collectNodeIds(node, output);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (node.children?.length) {
|
||||||
|
const found = collectDescendantIds(node.children, rootId, output);
|
||||||
|
if (found) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectNodeIds = (node: any, output: Set<string>) => {
|
||||||
|
output.add(String(node.id));
|
||||||
|
for (const child of node.children || []) {
|
||||||
|
collectNodeIds(child, output);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterTree = (nodes: any[], excludedIds: Set<string>): any[] => {
|
||||||
|
return (nodes || [])
|
||||||
|
.filter((node) => !excludedIds.has(String(node.id)))
|
||||||
|
.map((node) => ({
|
||||||
|
...node,
|
||||||
|
children: filterTree(node.children || [], excludedIds),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryTreeOptions = computed(() => categoryTree.value || []);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
reloadCategoryTree();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="faq-table-wrapper">
|
<div class="faq-table-wrapper">
|
||||||
<div class="faq-header">
|
<div class="faq-layout">
|
||||||
<HeaderSearch
|
<div class="faq-category-pane">
|
||||||
:buttons="headerButtons"
|
<div class="faq-category-header">
|
||||||
@search="handleSearch"
|
<span>{{ $t('documentCollection.faq.categoryTree') }}</span>
|
||||||
@button-click="handleButtonClick"
|
<ElButton
|
||||||
/>
|
link
|
||||||
</div>
|
type="primary"
|
||||||
|
:icon="Plus"
|
||||||
|
@click="openAddRootCategory"
|
||||||
|
>
|
||||||
|
{{ $t('button.add') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
<ElTree
|
||||||
|
class="faq-category-tree"
|
||||||
|
:data="treeData"
|
||||||
|
node-key="id"
|
||||||
|
default-expand-all
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
:current-node-key="selectedCategoryId"
|
||||||
|
:props="{ label: 'categoryName', children: 'children' }"
|
||||||
|
@node-click="handleCategoryClick"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<div
|
||||||
|
class="faq-category-node"
|
||||||
|
:class="{ 'is-all-node': data.isVirtual }"
|
||||||
|
>
|
||||||
|
<span class="faq-category-node-label">{{
|
||||||
|
data.categoryName
|
||||||
|
}}</span>
|
||||||
|
<div
|
||||||
|
v-if="!data.isVirtual && !data.isDefault"
|
||||||
|
class="faq-category-node-actions"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<ElDropdown trigger="click">
|
||||||
|
<ElButton link :icon="MoreFilled" />
|
||||||
|
<template #dropdown>
|
||||||
|
<ElDropdownMenu>
|
||||||
|
<ElDropdownItem
|
||||||
|
:icon="Top"
|
||||||
|
:disabled="!canMoveUp(data)"
|
||||||
|
@click="moveCategoryUp(data)"
|
||||||
|
>
|
||||||
|
{{ $t('documentCollection.faq.moveUp') }}
|
||||||
|
</ElDropdownItem>
|
||||||
|
<ElDropdownItem
|
||||||
|
:icon="Bottom"
|
||||||
|
:disabled="!canMoveDown(data)"
|
||||||
|
@click="moveCategoryDown(data)"
|
||||||
|
>
|
||||||
|
{{ $t('documentCollection.faq.moveDown') }}
|
||||||
|
</ElDropdownItem>
|
||||||
|
<ElDropdownItem
|
||||||
|
:icon="Upload"
|
||||||
|
:disabled="!canPromote(data)"
|
||||||
|
@click="promoteCategory(data)"
|
||||||
|
>
|
||||||
|
{{ $t('documentCollection.faq.promote') }}
|
||||||
|
</ElDropdownItem>
|
||||||
|
<ElDropdownItem
|
||||||
|
:icon="Download"
|
||||||
|
:disabled="!canDemote(data)"
|
||||||
|
@click="demoteCategory(data)"
|
||||||
|
>
|
||||||
|
{{ $t('documentCollection.faq.demote') }}
|
||||||
|
</ElDropdownItem>
|
||||||
|
<ElDropdownItem
|
||||||
|
:icon="CirclePlus"
|
||||||
|
@click="openAddSiblingCategory(data)"
|
||||||
|
>
|
||||||
|
{{ $t('documentCollection.faq.addSiblingCategory') }}
|
||||||
|
</ElDropdownItem>
|
||||||
|
<ElDropdownItem
|
||||||
|
:icon="FolderAdd"
|
||||||
|
:disabled="
|
||||||
|
Number(data.levelNo) >= 3 || !!data.isDefault
|
||||||
|
"
|
||||||
|
@click="openAddChildCategory(data)"
|
||||||
|
>
|
||||||
|
{{ $t('documentCollection.faq.addChildCategory') }}
|
||||||
|
</ElDropdownItem>
|
||||||
|
<ElDropdownItem :icon="Edit" @click="openEditCategory(data)">
|
||||||
|
{{ $t('button.edit') }}
|
||||||
|
</ElDropdownItem>
|
||||||
|
<ElDropdownItem
|
||||||
|
:icon="Delete"
|
||||||
|
:disabled="!!data.isDefault"
|
||||||
|
@click="removeCategory(data)"
|
||||||
|
>
|
||||||
|
{{ $t('button.delete') }}
|
||||||
|
</ElDropdownItem>
|
||||||
|
</ElDropdownMenu>
|
||||||
|
</template>
|
||||||
|
</ElDropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTree>
|
||||||
|
</div>
|
||||||
|
|
||||||
<PageData
|
<div class="faq-content-pane">
|
||||||
ref="pageDataRef"
|
<div class="faq-header">
|
||||||
page-url="/api/v1/faqItem/page"
|
<HeaderSearch
|
||||||
:page-size="10"
|
:buttons="headerButtons"
|
||||||
:extra-query-params="queryParams"
|
@search="handleSearch"
|
||||||
>
|
@button-click="handleButtonClick"
|
||||||
<template #default="{ pageList }">
|
|
||||||
<ElTable :data="pageList" size="large">
|
|
||||||
<ElTableColumn
|
|
||||||
prop="question"
|
|
||||||
:label="$t('documentCollection.faq.question')"
|
|
||||||
min-width="220"
|
|
||||||
/>
|
/>
|
||||||
<ElTableColumn
|
</div>
|
||||||
prop="answerText"
|
|
||||||
:label="$t('documentCollection.faq.answer')"
|
<PageData
|
||||||
min-width="260"
|
ref="pageDataRef"
|
||||||
show-overflow-tooltip
|
page-url="/api/v1/faqItem/page"
|
||||||
/>
|
:page-size="10"
|
||||||
<ElTableColumn :label="$t('common.handle')" width="170" align="right">
|
:extra-query-params="baseQueryParams"
|
||||||
<template #default="{ row }">
|
>
|
||||||
<ElButton link type="primary" :icon="Edit" @click="openEditDialog(row)">
|
<template #default="{ pageList }">
|
||||||
{{ $t('button.edit') }}
|
<ElTable :data="pageList" size="large">
|
||||||
</ElButton>
|
<ElTableColumn
|
||||||
<ElButton link type="danger" :icon="Delete" @click="removeFaq(row)">
|
prop="categoryPath"
|
||||||
{{ $t('button.delete') }}
|
:label="$t('documentCollection.faq.categoryPath')"
|
||||||
</ElButton>
|
min-width="220"
|
||||||
</template>
|
show-overflow-tooltip
|
||||||
</ElTableColumn>
|
/>
|
||||||
</ElTable>
|
<ElTableColumn
|
||||||
</template>
|
prop="question"
|
||||||
</PageData>
|
:label="$t('documentCollection.faq.question')"
|
||||||
|
min-width="220"
|
||||||
|
/>
|
||||||
|
<ElTableColumn
|
||||||
|
prop="answerText"
|
||||||
|
:label="$t('documentCollection.faq.answer')"
|
||||||
|
min-width="260"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<ElTableColumn
|
||||||
|
:label="$t('common.handle')"
|
||||||
|
width="170"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:icon="Edit"
|
||||||
|
@click="openEditDialog(row)"
|
||||||
|
>
|
||||||
|
{{ $t('button.edit') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:icon="Delete"
|
||||||
|
@click="removeFaq(row)"
|
||||||
|
>
|
||||||
|
{{ $t('button.delete') }}
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
</template>
|
||||||
|
</PageData>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FaqEditDialog
|
<FaqEditDialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
:data="editData"
|
:data="editData"
|
||||||
|
:category-options="categoryTreeOptions"
|
||||||
@submit="saveFaq"
|
@submit="saveFaq"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FaqCategoryDialog
|
||||||
|
v-model="categoryDialogVisible"
|
||||||
|
:title="categoryDialogTitle"
|
||||||
|
:data="categoryEditData"
|
||||||
|
:disable-parent="categoryDialogDisableParent"
|
||||||
|
:parent-options="categoryParentOptions"
|
||||||
|
@submit="saveCategory"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.faq-table-wrapper {
|
.faq-table-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: calc(100vh - 220px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-category-pane {
|
||||||
|
width: 236px;
|
||||||
|
min-width: 236px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-category-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-category-tree {
|
||||||
|
padding: 6px;
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-category-node {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-category-node.is-all-node .faq-category-node-label {
|
||||||
|
padding-left: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-category-node-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-category-node-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-content-pane {
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 14px 16px 8px;
|
padding: 14px 16px 8px;
|
||||||
background: var(--el-fill-color-blank);
|
background: var(--el-fill-color-blank);
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-header {
|
.faq-header {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(
|
||||||
|
.faq-category-tree > .el-tree-node > .el-tree-node__content > .el-tree-node__expand-icon
|
||||||
|
) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.faq-category-tree .el-tree-node__content) {
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.faq-category-tree .el-tree-node__content:hover) {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.faq-category-tree .el-tree-node__content:hover .faq-category-node-actions) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tree-node.is-current > .el-tree-node__content) {
|
||||||
|
background-color: hsl(var(--primary) / 15%);
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tree-node.is-current > .el-tree-node__content .faq-category-node-actions) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.el-table) {
|
:deep(.el-table) {
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -285,6 +285,31 @@ CREATE TABLE `tb_document_collection_category`
|
|||||||
PRIMARY KEY (`id`) USING BTREE
|
PRIMARY KEY (`id`) USING BTREE
|
||||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table structure for tb_faq_category
|
||||||
|
-- ----------------------------
|
||||||
|
DROP TABLE IF EXISTS `tb_faq_category`;
|
||||||
|
CREATE TABLE `tb_faq_category`
|
||||||
|
(
|
||||||
|
`id` bigint UNSIGNED NOT NULL COMMENT '主键',
|
||||||
|
`collection_id` bigint UNSIGNED NOT NULL COMMENT '知识库ID',
|
||||||
|
`parent_id` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '父分类ID,0表示根',
|
||||||
|
`ancestors` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '0' COMMENT '祖先路径(逗号分隔)',
|
||||||
|
`level_no` tinyint UNSIGNED NOT NULL DEFAULT 1 COMMENT '层级(1-3)',
|
||||||
|
`category_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '分类名称',
|
||||||
|
`sort_no` int NOT NULL DEFAULT 0 COMMENT '排序',
|
||||||
|
`is_default` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否默认分类',
|
||||||
|
`status` int NOT NULL DEFAULT 0 COMMENT '数据状态',
|
||||||
|
`created` datetime NULL DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`created_by` bigint UNSIGNED NULL DEFAULT NULL COMMENT '创建人',
|
||||||
|
`modified` datetime NULL DEFAULT NULL COMMENT '更新时间',
|
||||||
|
`modified_by` bigint UNSIGNED NULL DEFAULT NULL COMMENT '更新人',
|
||||||
|
PRIMARY KEY (`id`) USING BTREE,
|
||||||
|
INDEX `idx_faq_category_collection_parent_sort`(`collection_id`, `parent_id`, `sort_no`) USING BTREE,
|
||||||
|
INDEX `idx_faq_category_collection_level`(`collection_id`, `level_no`) USING BTREE,
|
||||||
|
INDEX `idx_faq_category_collection_status`(`collection_id`, `status`) USING BTREE
|
||||||
|
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'FAQ分类' ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
-- Table structure for tb_faq_item
|
-- Table structure for tb_faq_item
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
@@ -293,6 +318,7 @@ CREATE TABLE `tb_faq_item`
|
|||||||
(
|
(
|
||||||
`id` bigint UNSIGNED NOT NULL COMMENT '主键',
|
`id` bigint UNSIGNED NOT NULL COMMENT '主键',
|
||||||
`collection_id` bigint UNSIGNED NOT NULL COMMENT '知识库ID',
|
`collection_id` bigint UNSIGNED NOT NULL COMMENT '知识库ID',
|
||||||
|
`category_id` bigint UNSIGNED NULL DEFAULT NULL COMMENT 'FAQ分类ID',
|
||||||
`question` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '问题',
|
`question` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '问题',
|
||||||
`answer_html` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '答案HTML',
|
`answer_html` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '答案HTML',
|
||||||
`answer_text` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '答案纯文本',
|
`answer_text` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '答案纯文本',
|
||||||
@@ -304,7 +330,8 @@ CREATE TABLE `tb_faq_item`
|
|||||||
`modified_by` bigint UNSIGNED NULL DEFAULT NULL COMMENT '更新人',
|
`modified_by` bigint UNSIGNED NULL DEFAULT NULL COMMENT '更新人',
|
||||||
PRIMARY KEY (`id`) USING BTREE,
|
PRIMARY KEY (`id`) USING BTREE,
|
||||||
INDEX `idx_faq_collection_id`(`collection_id`) USING BTREE,
|
INDEX `idx_faq_collection_id`(`collection_id`) USING BTREE,
|
||||||
INDEX `idx_faq_collection_order`(`collection_id`, `order_no`) USING BTREE
|
INDEX `idx_faq_collection_order`(`collection_id`, `order_no`) USING BTREE,
|
||||||
|
INDEX `idx_faq_collection_category_order`(`collection_id`, `category_id`, `order_no`) USING BTREE
|
||||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'FAQ条目' ROW_FORMAT = Dynamic;
|
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'FAQ条目' ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
|
|||||||
62
sql/06-easyflow-v2.p3-faq-category.sql
Normal file
62
sql/06-easyflow-v2.p3-faq-category.sql
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
-- P3: FAQ分类树增强(最多三级)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_faq_category
|
||||||
|
(
|
||||||
|
id bigint UNSIGNED NOT NULL COMMENT '主键',
|
||||||
|
collection_id bigint UNSIGNED NOT NULL COMMENT '知识库ID',
|
||||||
|
parent_id bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '父分类ID,0表示根',
|
||||||
|
ancestors varchar(512) NOT NULL DEFAULT '0' COMMENT '祖先路径(逗号分隔)',
|
||||||
|
level_no tinyint UNSIGNED NOT NULL DEFAULT 1 COMMENT '层级(1-3)',
|
||||||
|
category_name varchar(64) NOT NULL COMMENT '分类名称',
|
||||||
|
sort_no int NOT NULL DEFAULT 0 COMMENT '排序',
|
||||||
|
is_default tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否默认分类',
|
||||||
|
status int NOT NULL DEFAULT 0 COMMENT '数据状态',
|
||||||
|
created datetime NULL COMMENT '创建时间',
|
||||||
|
created_by bigint UNSIGNED NULL COMMENT '创建人',
|
||||||
|
modified datetime NULL COMMENT '更新时间',
|
||||||
|
modified_by bigint UNSIGNED NULL COMMENT '更新人',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
INDEX idx_faq_category_collection_parent_sort (collection_id, parent_id, sort_no),
|
||||||
|
INDEX idx_faq_category_collection_level (collection_id, level_no),
|
||||||
|
INDEX idx_faq_category_collection_status (collection_id, status)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT ='FAQ分类';
|
||||||
|
|
||||||
|
-- 兼容低版本 MySQL:不使用 ADD COLUMN / ADD INDEX IF NOT EXISTS
|
||||||
|
SET @faq_item_category_col_exists := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'tb_faq_item'
|
||||||
|
AND COLUMN_NAME = 'category_id'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @faq_item_add_category_col_sql := IF(
|
||||||
|
@faq_item_category_col_exists = 0,
|
||||||
|
'ALTER TABLE tb_faq_item ADD COLUMN category_id bigint UNSIGNED NULL DEFAULT NULL COMMENT ''FAQ分类ID'' AFTER collection_id',
|
||||||
|
'DO 0'
|
||||||
|
);
|
||||||
|
PREPARE stmt_add_faq_item_category_col FROM @faq_item_add_category_col_sql;
|
||||||
|
EXECUTE stmt_add_faq_item_category_col;
|
||||||
|
DEALLOCATE PREPARE stmt_add_faq_item_category_col;
|
||||||
|
|
||||||
|
SET @faq_item_category_idx_exists := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'tb_faq_item'
|
||||||
|
AND INDEX_NAME = 'idx_faq_collection_category_order'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @faq_item_add_category_idx_sql := IF(
|
||||||
|
@faq_item_category_idx_exists = 0,
|
||||||
|
'ALTER TABLE tb_faq_item ADD INDEX idx_faq_collection_category_order (collection_id, category_id, order_no)',
|
||||||
|
'DO 0'
|
||||||
|
);
|
||||||
|
PREPARE stmt_add_faq_item_category_idx FROM @faq_item_add_category_idx_sql;
|
||||||
|
EXECUTE stmt_add_faq_item_category_idx;
|
||||||
|
DEALLOCATE PREPARE stmt_add_faq_item_category_idx;
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
@@ -285,6 +285,31 @@ CREATE TABLE `tb_document_collection_category`
|
|||||||
PRIMARY KEY (`id`) USING BTREE
|
PRIMARY KEY (`id`) USING BTREE
|
||||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table structure for tb_faq_category
|
||||||
|
-- ----------------------------
|
||||||
|
DROP TABLE IF EXISTS `tb_faq_category`;
|
||||||
|
CREATE TABLE `tb_faq_category`
|
||||||
|
(
|
||||||
|
`id` bigint UNSIGNED NOT NULL COMMENT '主键',
|
||||||
|
`collection_id` bigint UNSIGNED NOT NULL COMMENT '知识库ID',
|
||||||
|
`parent_id` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '父分类ID,0表示根',
|
||||||
|
`ancestors` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '0' COMMENT '祖先路径(逗号分隔)',
|
||||||
|
`level_no` tinyint UNSIGNED NOT NULL DEFAULT 1 COMMENT '层级(1-3)',
|
||||||
|
`category_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '分类名称',
|
||||||
|
`sort_no` int NOT NULL DEFAULT 0 COMMENT '排序',
|
||||||
|
`is_default` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否默认分类',
|
||||||
|
`status` int NOT NULL DEFAULT 0 COMMENT '数据状态',
|
||||||
|
`created` datetime NULL DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`created_by` bigint UNSIGNED NULL DEFAULT NULL COMMENT '创建人',
|
||||||
|
`modified` datetime NULL DEFAULT NULL COMMENT '更新时间',
|
||||||
|
`modified_by` bigint UNSIGNED NULL DEFAULT NULL COMMENT '更新人',
|
||||||
|
PRIMARY KEY (`id`) USING BTREE,
|
||||||
|
INDEX `idx_faq_category_collection_parent_sort`(`collection_id`, `parent_id`, `sort_no`) USING BTREE,
|
||||||
|
INDEX `idx_faq_category_collection_level`(`collection_id`, `level_no`) USING BTREE,
|
||||||
|
INDEX `idx_faq_category_collection_status`(`collection_id`, `status`) USING BTREE
|
||||||
|
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'FAQ分类' ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
-- Table structure for tb_faq_item
|
-- Table structure for tb_faq_item
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
@@ -293,6 +318,7 @@ CREATE TABLE `tb_faq_item`
|
|||||||
(
|
(
|
||||||
`id` bigint UNSIGNED NOT NULL COMMENT '主键',
|
`id` bigint UNSIGNED NOT NULL COMMENT '主键',
|
||||||
`collection_id` bigint UNSIGNED NOT NULL COMMENT '知识库ID',
|
`collection_id` bigint UNSIGNED NOT NULL COMMENT '知识库ID',
|
||||||
|
`category_id` bigint UNSIGNED NULL DEFAULT NULL COMMENT 'FAQ分类ID',
|
||||||
`question` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '问题',
|
`question` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '问题',
|
||||||
`answer_html` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '答案HTML',
|
`answer_html` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '答案HTML',
|
||||||
`answer_text` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '答案纯文本',
|
`answer_text` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '答案纯文本',
|
||||||
@@ -304,7 +330,8 @@ CREATE TABLE `tb_faq_item`
|
|||||||
`modified_by` bigint UNSIGNED NULL DEFAULT NULL COMMENT '更新人',
|
`modified_by` bigint UNSIGNED NULL DEFAULT NULL COMMENT '更新人',
|
||||||
PRIMARY KEY (`id`) USING BTREE,
|
PRIMARY KEY (`id`) USING BTREE,
|
||||||
INDEX `idx_faq_collection_id`(`collection_id`) USING BTREE,
|
INDEX `idx_faq_collection_id`(`collection_id`) USING BTREE,
|
||||||
INDEX `idx_faq_collection_order`(`collection_id`, `order_no`) USING BTREE
|
INDEX `idx_faq_collection_order`(`collection_id`, `order_no`) USING BTREE,
|
||||||
|
INDEX `idx_faq_collection_category_order`(`collection_id`, `category_id`, `order_no`) USING BTREE
|
||||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'FAQ条目' ROW_FORMAT = Dynamic;
|
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'FAQ条目' ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user