refactor: 文档菜单重构

1. 无限层级菜单
2. 文档拖拽排序
This commit is contained in:
xiaozzzi 2024-04-04 15:41:00 +08:00
parent a246b0f055
commit 64a8d730a6
67 changed files with 3232 additions and 2335 deletions

View File

@ -1,6 +1,5 @@
package com.blossom.backend.base.search.message.consumer;
import cn.hutool.core.convert.Convert;
import com.blossom.backend.base.search.SearchProperties;
import com.blossom.backend.base.search.message.IndexMsg;
import com.blossom.backend.base.search.message.IndexMsgTypeEnum;
@ -51,7 +50,6 @@ public class IndexMsgConsumer {
final Long userId = indexMsg.getUserId();
final Long id = indexMsg.getId();
if (userId == null || id == null) {
log.error("消费异常. 获取用户id为空");
continue;
}
if (IndexMsgTypeEnum.ADD == indexMsg.getType()) {
@ -62,11 +60,11 @@ public class IndexMsgConsumer {
// 查询最新的消息
ArticleEntity article = this.articleService.selectById(id, false, true, false, userId);
Document document = new Document();
document.add(new StringField("id", Convert.toStr(id), Field.Store.YES));
document.add(new StringField("id", String.valueOf(id), Field.Store.YES));
document.add(new TextField("name", article.getName(), Field.Store.YES));
document.add(new TextField("tags", article.getTags(), Field.Store.YES));
document.add(new TextField("markdown", article.getMarkdown(), Field.Store.YES));
indexWriter.updateDocument(new Term("id", Convert.toStr(id)), document);
indexWriter.updateDocument(new Term("id", String.valueOf(id)), document);
indexWriter.flush();
indexWriter.commit();
}
@ -74,7 +72,7 @@ public class IndexMsgConsumer {
// 删除索引
try (Directory directory = FSDirectory.open(this.searchProperties.getUserIndexDirectory(userId));
IndexWriter indexWriter = new IndexWriter(directory, new IndexWriterConfig(new StandardAnalyzer()))) {
indexWriter.deleteDocuments(new Term("id", Convert.toStr(id)));
indexWriter.deleteDocuments(new Term("id", String.valueOf(id)));
indexWriter.flush();
indexWriter.commit();
}

View File

@ -1,11 +1,33 @@
package com.blossom.backend.config;
import com.blossom.backend.server.folder.pojo.FolderEntity;
import lombok.extern.slf4j.Slf4j;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
@Slf4j
public class Test {
public static void main(String[] args) {
Set<FolderEntity> set = new HashSet<>();
FolderEntity f1 = new FolderEntity();
f1.setId(1L);
f1.setName("1");
FolderEntity f2 = new FolderEntity();
f2.setId(1L);
f2.setName("2");
set.add(f1);
set.add(f2);
System.out.println(set.size());
System.out.println(Arrays.toString(set.toArray()));
}
}

View File

@ -12,8 +12,10 @@ import com.blossom.backend.server.article.draft.pojo.*;
import com.blossom.backend.server.article.open.ArticleOpenService;
import com.blossom.backend.server.article.open.pojo.ArticleOpenEntity;
import com.blossom.backend.server.doc.DocService;
import com.blossom.backend.server.doc.DocSortChecker;
import com.blossom.backend.server.doc.DocTypeEnum;
import com.blossom.backend.server.folder.FolderService;
import com.blossom.backend.server.folder.FolderTypeEnum;
import com.blossom.backend.server.folder.pojo.FolderEntity;
import com.blossom.backend.server.utils.ArticleUtil;
import com.blossom.backend.server.utils.DocUtil;
@ -50,13 +52,14 @@ import java.util.List;
@AllArgsConstructor
@RequestMapping("/article")
public class ArticleController {
private final ArticleService baseService;
private final ArticleOpenService openService;
private final FolderService folderService;
private final UserService userService;
private final ArticleTempVisitService tempVisitService;
private final DocService docService;
private final DocSortChecker docSortChecker;
private final ImportManager importManager;
/**
* 查询列表
@ -122,9 +125,9 @@ public class ArticleController {
ArticleEntity article = req.to(ArticleEntity.class);
article.setTags(DocUtil.toTagStr(req.getTags()));
article.setUserId(AuthContext.getUserId());
// 如果新增到顶部, 获取最小的
// 如果新增到顶部, 获取最小的排序
if (BooleanUtil.isTrue(req.getAddToLast())) {
article.setSort(docService.selectMinSortByPid(req.getPid()) + 1);
article.setSort(docService.selectMaxSortByPid(req.getPid(), AuthContext.getUserId(), FolderTypeEnum.ARTICLE) + 1);
}
return R.ok(baseService.insert(article));
}
@ -136,10 +139,19 @@ public class ArticleController {
* @apiNote 该接口只能修改文章的基本信息, 正文及版本修改请使用 "/upd/content" 接口或者 {@link ArticleService#updateContentById(ArticleEntity)}
*/
@PostMapping("/upd")
public R<Long> insert(@Validated @RequestBody ArticleUpdReq req) {
public R<Long> update(@Validated @RequestBody ArticleUpdReq req) {
ArticleEntity article = req.to(ArticleEntity.class);
article.setTags(DocUtil.toTagStr(req.getTags()));
article.setUserId(AuthContext.getUserId());
// 检查排序是否重复
// if (req.getSort() != null && req.getPid() != null) {
// final long newPid = req.getPid();
// docSortChecker.checkUnique(CollUtil.newArrayList(newPid),
// null,
// CollUtil.newArrayList(article),
// FolderTypeEnum.ARTICLE,
// AuthContext.getUserId());
// }
return R.ok(baseService.update(article));
}
@ -263,11 +275,11 @@ public class ArticleController {
* @param pid 上级菜单
*/
@PostMapping("import")
public R<?> upload(@RequestParam("file") MultipartFile file, @RequestParam(value = "pid") Long pid) {
public R<?> upload(@RequestParam("file") MultipartFile file, @RequestParam(value = "pid") Long pid, @RequestParam(value = "batchId") String batchId) {
try {
String suffix = FileUtil.getSuffix(file.getOriginalFilename());
if (!"txt".equals(suffix) && !"md".equals(suffix)) {
throw new XzException404("不支持的文件类型: [" + suffix + "]");
throw new XzException400("不支持的文件类型: [" + suffix + "]");
}
FolderEntity folder = folderService.selectById(pid);
XzException404.throwBy(ObjUtil.isNull(folder), "上级文件夹不存在");
@ -278,10 +290,12 @@ public class ArticleController {
article.setPid(pid);
article.setUserId(AuthContext.getUserId());
article.setName(FileUtil.getPrefix(file.getOriginalFilename()));
article.setWords(ArticleUtil.statWords(content));
// article.setWords(ArticleUtil.statWords(content));
article.setSort(importManager.getSort(batchId, pid, AuthContext.getUserId()));
baseService.insert(article);
} catch (Exception e) {
e.printStackTrace();
throw new XzException400("上传失败");
}
return R.ok();
}

View File

@ -2,6 +2,7 @@ package com.blossom.backend.server.article.draft;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.blossom.backend.base.search.EnableIndex;
@ -166,7 +167,9 @@ public class ArticleService extends ServiceImpl<ArticleMapper, ArticleEntity> {
public Long update(ArticleEntity req) {
XzException404.throwBy(req.getId() == null, "ID不得为空");
baseMapper.updById(req);
referenceService.updateInnerName(req.getUserId(), req.getId(), req.getName());
if(StrUtil.isNotBlank(req.getName())) {
referenceService.updateInnerName(req.getUserId(), req.getId(), req.getName());
}
return req.getId();
}

View File

@ -0,0 +1,64 @@
package com.blossom.backend.server.article.draft;
import com.blossom.backend.server.doc.DocService;
import com.blossom.backend.server.folder.FolderTypeEnum;
import com.blossom.common.base.exception.XzException500;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.RemovalCause;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
@Slf4j
@Component
@AllArgsConstructor
public class ImportManager {
private final DocService docService;
private final ReentrantLock LOCK = new ReentrantLock();
/**
* 批量导入时的批次缓存, 一个批次的导入只会从数据库获取一次排序, 后续排序从缓存中递增
*/
private final Cache<String, AtomicInteger> batchCache = Caffeine.newBuilder()
.initialCapacity(50)
.expireAfterWrite(120, TimeUnit.MINUTES)
.removalListener((String location, AtomicInteger i, RemovalCause cause) ->
log.info("batch import [" + location + "] has been deleted")
)
.build();
/**
* 并发导入时的排序获取
*/
public Integer getSort(String batchId, Long pid, Long userId) {
AtomicInteger sort = batchCache.getIfPresent(batchId);
if (null == sort) {
try {
LOCK.tryLock(1000, TimeUnit.MILLISECONDS);
sort = batchCache.getIfPresent(batchId);
if (null == sort) {
sort = initBatchCount(batchId, pid, userId);
}
} catch (InterruptedException e) {
LOCK.unlock();
}
}
if (sort == null) {
throw new XzException500("导入失败");
}
return sort.getAndIncrement();
}
private AtomicInteger initBatchCount(String batchId, Long pid, Long userId) {
System.out.println("初始化导入排序");
AtomicInteger i = new AtomicInteger(docService.selectMaxSortByPid(pid, userId, FolderTypeEnum.ARTICLE) + 1);
batchCache.put(batchId, i);
return i;
}
}

View File

@ -5,6 +5,8 @@ import com.blossom.backend.base.auth.annotation.AuthIgnore;
import com.blossom.backend.config.BlConstants;
import com.blossom.backend.server.doc.pojo.DocTreeReq;
import com.blossom.backend.server.doc.pojo.DocTreeRes;
import com.blossom.backend.server.doc.pojo.DocTreeUpdSortReq;
import com.blossom.backend.server.folder.FolderTypeEnum;
import com.blossom.common.base.pojo.R;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;
@ -29,7 +31,7 @@ public class DocController {
/**
* 文档列表
*
* @return 件夹列表
* @return 列表
* @apiNote 文档包含文章和文件夹, 文件夹分为图片文件夹和文章文件夹 {@link DocTypeEnum}
*/
@GetMapping("/trees")
@ -56,4 +58,21 @@ public class DocController {
open.setUserId(userId);
return R.ok(docService.listTree(open));
}
/**
* 修改排序
* <p/>todo 返回列表, 兼容列表查询条件
*
* @param tree 需要修改排序的文档列表
* @return 文档列表
* @since 1.14.0
*/
@PostMapping("/upd/sort")
public R<List<DocTreeRes>> updSort(@RequestBody DocTreeUpdSortReq tree) {
docService.updSort(tree.getDocs(), AuthContext.getUserId(), FolderTypeEnum.getType(tree.getFolderType()));
DocTreeReq req = new DocTreeReq();
req.setUserId(AuthContext.getUserId());
req.setOnlyPicture(tree.getOnlyPicture());
return R.ok(docService.listTree(req));
}
}

View File

@ -8,9 +8,10 @@ public interface DocMapper {
/**
* 查询 PID 下的最小排序
*
* @param pid PID
* @return 最小排序
*/
Integer selectMaxSortByPid(@Param("pid") Long pid);
Integer selectMaxSortByPid(@Param("pid") Long pid, @Param("userId") Long userId, @Param("type") Integer type);
}

View File

@ -1,14 +1,19 @@
package com.blossom.backend.server.doc;
import cn.hutool.core.collection.CollUtil;
import com.blossom.backend.base.auth.AuthContext;
import com.blossom.backend.server.article.TagEnum;
import com.blossom.backend.server.article.draft.ArticleMapper;
import com.blossom.backend.server.article.draft.ArticleService;
import com.blossom.backend.server.article.draft.pojo.ArticleEntity;
import com.blossom.backend.server.article.draft.pojo.ArticleQueryReq;
import com.blossom.backend.server.doc.pojo.DocTreeReq;
import com.blossom.backend.server.doc.pojo.DocTreeRes;
import com.blossom.backend.server.doc.pojo.DocTreeUpdSortReq;
import com.blossom.backend.server.folder.FolderMapper;
import com.blossom.backend.server.folder.FolderService;
import com.blossom.backend.server.folder.FolderTypeEnum;
import com.blossom.backend.server.folder.pojo.FolderQueryReq;
import com.blossom.backend.server.folder.pojo.FolderEntity;
import com.blossom.backend.server.picture.PictureService;
import com.blossom.backend.server.utils.DocUtil;
import com.blossom.backend.server.utils.PictureUtil;
@ -16,6 +21,7 @@ import com.blossom.common.base.enums.YesNo;
import com.blossom.common.base.util.SortUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@ -29,23 +35,28 @@ import java.util.stream.Collectors;
*/
@Service
public class DocService {
private FolderService folderService;
private ArticleService articleService;
private PictureService pictureService;
private FolderService folderService;
@Autowired
private DocMapper baseMapper;
@Autowired
public void setFolderService(FolderService folderService) {
this.folderService = folderService;
}
private FolderMapper folderMapper;
@Autowired
private ArticleMapper articleMapper;
@Autowired
private DocSortChecker docSortChecker;
@Autowired
public void setArticleService(ArticleService articleService) {
this.articleService = articleService;
}
@Autowired
public void setFolderService(FolderService folderService) {
this.folderService = folderService;
}
@Autowired
public void setPictureService(PictureService pictureService) {
this.pictureService = pictureService;
@ -64,10 +75,10 @@ public class DocService {
* 只查询文件夹
* =============================================================================================== */
if (req.getOnlyFolder()) {
FolderQueryReq where = req.to(FolderQueryReq.class);
List<DocTreeRes> folder = folderService.listTree(where);
FolderEntity where = req.to(FolderEntity.class);
List<FolderEntity> folder = folderMapper.listAll(where);
all.addAll(CollUtil.newArrayList(PictureUtil.getDefaultFolder(req.getUserId())));
all.addAll(folder);
all.addAll(DocUtil.toTreeRes(folder));
priorityType = true;
}
/* ===============================================================================================
@ -75,16 +86,16 @@ public class DocService {
* =============================================================================================== */
else if (req.getOnlyPicture()) {
// 1. 所有图片文件夹
FolderQueryReq folder = req.to(FolderQueryReq.class);
folder.setType(FolderTypeEnum.PICTURE.getType());
List<DocTreeRes> picFolder = folderService.listTree(folder);
all.addAll(picFolder);
FolderEntity where = req.to(FolderEntity.class);
where.setType(FolderTypeEnum.PICTURE.getType());
List<FolderEntity> picFolder = folderMapper.listAll(where);
all.addAll(DocUtil.toTreeRes(picFolder));
// 2. 有图片的图片或文章文件夹
List<Long> pids = pictureService.listDistinctPid(req.getUserId());
if (CollUtil.isNotEmpty(pids)) {
List<DocTreeRes> articleTopFolder = folderService.recursiveToParentTree(pids);
all.addAll(articleTopFolder);
List<FolderEntity> articleTopFolder = folderMapper.recursiveToParent(pids);
all.addAll(DocUtil.toTreeRes(articleTopFolder));
}
Optional<DocTreeRes> min = all.stream().min((f1, f2) -> SortUtil.intSort.compare(f1.getS(), f2.getS()));
@ -105,24 +116,24 @@ public class DocService {
all.addAll(articles);
if (CollUtil.isNotEmpty(articles)) {
List<Long> pidList = articles.stream().map(DocTreeRes::getP).collect(Collectors.toList());
List<DocTreeRes> folders = folderService.recursiveToParentTree(pidList);
all.addAll(folders);
List<FolderEntity> folders = folderMapper.recursiveToParent(pidList);
all.addAll(DocUtil.toTreeRes(folders));
}
}
/* ===============================================================================================
* 只查询专题的文章和文件夹
* =============================================================================================== */
else if (req.getOnlySubject()) {
FolderQueryReq folderWhere = req.to(FolderQueryReq.class);
FolderEntity folderWhere = req.to(FolderEntity.class);
folderWhere.setTags(TagEnum.subject.name());
folderWhere.setType(FolderTypeEnum.ARTICLE.getType());
List<DocTreeRes> subjects = folderService.listTree(folderWhere);
List<FolderEntity> subjects = folderMapper.listAll(folderWhere);
if (CollUtil.isNotEmpty(subjects)) {
List<Long> subjectIds = subjects.stream().map(DocTreeRes::getI).collect(Collectors.toList());
List<DocTreeRes> foldersTop = folderService.recursiveToParentTree(subjectIds);
List<DocTreeRes> foldersBottom = folderService.recursiveToChildrenTree(subjectIds);
all.addAll(foldersTop);
all.addAll(foldersBottom);
List<Long> subjectIds = subjects.stream().map(FolderEntity::getId).collect(Collectors.toList());
List<FolderEntity> foldersTop = folderMapper.recursiveToParent(subjectIds);
List<FolderEntity> foldersBottom = folderMapper.recursiveToChildren(subjectIds);
all.addAll(DocUtil.toTreeRes(foldersTop));
all.addAll(DocUtil.toTreeRes(foldersBottom));
}
List<DocTreeRes> articles = articleService.listTree(req.to(ArticleQueryReq.class));
all.addAll(articles);
@ -137,8 +148,8 @@ public class DocService {
all.addAll(articles);
if (CollUtil.isNotEmpty(articles)) {
List<Long> pidList = articles.stream().map(DocTreeRes::getP).collect(Collectors.toList());
List<DocTreeRes> folders = folderService.recursiveToParentTree(pidList);
all.addAll(folders);
List<FolderEntity> folders = folderMapper.recursiveToParent(pidList);
all.addAll(DocUtil.toTreeRes(folders));
}
}
/* ===============================================================================================
@ -151,19 +162,19 @@ public class DocService {
all.addAll(articles);
if (CollUtil.isNotEmpty(articles)) {
List<Long> pidList = articles.stream().map(DocTreeRes::getP).collect(Collectors.toList());
List<DocTreeRes> folders = folderService.recursiveToParentTree(pidList);
all.addAll(folders);
List<FolderEntity> folders = folderMapper.recursiveToParent(pidList);
all.addAll(DocUtil.toTreeRes(folders));
}
}
/* ===============================================================================================
* 默认查询文章文件夹
* =============================================================================================== */
else {
FolderQueryReq folder = req.to(FolderQueryReq.class);
FolderEntity folder = req.to(FolderEntity.class);
folder.setType(FolderTypeEnum.ARTICLE.getType());
List<DocTreeRes> folders = folderService.listTree(folder);
List<FolderEntity> folders = folderMapper.listAll(folder);
List<DocTreeRes> articles = articleService.listTree(req.to(ArticleQueryReq.class));
all.addAll(folders);
all.addAll(DocUtil.toTreeRes(folders));
all.addAll(articles);
}
@ -177,8 +188,64 @@ public class DocService {
* @return 最大排序
* @since 1.10.0
*/
public int selectMinSortByPid(Long pid) {
return baseMapper.selectMaxSortByPid(pid);
public int selectMaxSortByPid(Long pid, Long userId, FolderTypeEnum type) {
return baseMapper.selectMaxSortByPid(pid, userId, type.getType());
}
/**
* 修改排序
*
* @param docs 需要修改的文档
* @since 1.14.0
*/
@Transactional(rollbackFor = Exception.class)
public void updSort(List<DocTreeUpdSortReq.Doc> docs, Long userId, FolderTypeEnum folderType) {
// 提取所有需要修改的文档的父文档
List<Long> pids = docs.stream().map(DocTreeUpdSortReq.Doc::getP).collect(Collectors.toList());
List<ArticleEntity> articles = new ArrayList<>();
List<FolderEntity> folders = new ArrayList<>();
// 一次拖拽所修改的文档可能包含文章和文件夹, 需要归类和转为 Entity
for (DocTreeUpdSortReq.Doc doc : docs) {
if (DocTypeEnum.A.getType().equals(doc.getTy())) {
ArticleEntity a = new ArticleEntity();
a.setId(doc.getI());
a.setPid(doc.getP());
a.setName(doc.getN());
a.setSort(doc.getS());
articles.add(a);
}
if (DocTypeEnum.FA.getType().equals(doc.getTy())) {
FolderEntity f = new FolderEntity();
f.setId(doc.getI());
f.setPid(doc.getP());
f.setName(doc.getN());
f.setSort(doc.getS());
folders.add(f);
}
}
docSortChecker.checkUnique(pids, folders, articles, folderType, userId);
for (DocTreeUpdSortReq.Doc tree : docs) {
if (DocTypeEnum.FA.getType().equals(tree.getTy()) || DocTypeEnum.FP.getType().equals(tree.getTy())) {
FolderEntity f = new FolderEntity();
f.setId(tree.getI());
f.setPid(tree.getP());
f.setSort(tree.getS());
f.setUserId(AuthContext.getUserId());
f.setStorePath(tree.getSp());
folderService.update(f);
} else if (DocTypeEnum.A.getType().equals(tree.getTy())) {
ArticleEntity a = new ArticleEntity();
a.setId(tree.getI());
a.setPid(tree.getP());
a.setSort(tree.getS());
a.setUserId(AuthContext.getUserId());
articleService.update(a);
}
}
}
}

View File

@ -0,0 +1,116 @@
package com.blossom.backend.server.doc;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil;
import com.blossom.backend.server.article.draft.ArticleMapper;
import com.blossom.backend.server.article.draft.pojo.ArticleEntity;
import com.blossom.backend.server.doc.pojo.DocTreeRes;
import com.blossom.backend.server.folder.FolderMapper;
import com.blossom.backend.server.folder.FolderTypeEnum;
import com.blossom.backend.server.folder.pojo.FolderEntity;
import com.blossom.backend.server.utils.DocUtil;
import com.blossom.common.base.exception.XzException400;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Component
public class DocSortChecker {
@Autowired
private FolderMapper folderMapper;
@Autowired
private ArticleMapper articleMapper;
/**
* 检查 pid 下是否有重复的文档排序
*
* @param pids pid
* @param newFs 新文件夹
* @param newAs 新文章
* @return
*/
public boolean checkUnique(List<Long> pids,
List<FolderEntity> newFs,
List<ArticleEntity> newAs,
FolderTypeEnum folderType,
Long userId) {
Map<Long, FolderEntity> newFMap = CollUtil.isEmpty(newFs) ? new HashMap<>() : newFs.stream().collect(Collectors.toMap(FolderEntity::getId, doc -> doc));
Map<Long, ArticleEntity> newAMap = CollUtil.isEmpty(newAs) ? new HashMap<>() : newAs.stream().collect(Collectors.toMap(ArticleEntity::getId, doc -> doc));
List<DocTreeRes> allDoc = new ArrayList<>();
// 获取文件夹排序
FolderEntity fByPid = new FolderEntity();
fByPid.setPids(pids);
fByPid.setType(folderType.getType());
fByPid.setUserId(userId);
List<FolderEntity> folders = folderMapper.listAll(fByPid);
if (CollUtil.isNotEmpty(folders)) {
for (FolderEntity f : folders) {
FolderEntity newF = newFMap.get(f.getId());
if (newF != null) {
f.setPid(newF.getPid());
f.setSort(newF.getSort());
newFMap.remove(f.getId());
}
allDoc.add(DocUtil.toDocTree(f));
}
}
if (MapUtil.isNotEmpty(newFMap)) {
for (FolderEntity f : newFMap.values()) {
allDoc.add(DocUtil.toDocTree(f));
}
}
// 只有处理文章排序时, 才需要获取文章排序
if(FolderTypeEnum.ARTICLE.equals(folderType)) {
ArticleEntity aByPid = new ArticleEntity();
aByPid.setPids(pids);
aByPid.setUserId(userId);
List<ArticleEntity> articles = articleMapper.listAll(aByPid);
if (CollUtil.isNotEmpty(articles)) {
for (ArticleEntity a : articles) {
ArticleEntity newA = newAMap.get(a.getId());
if (newA != null) {
a.setPid(newA.getPid());
a.setSort(newA.getSort());
newAMap.remove(a.getId());
}
allDoc.add(DocUtil.toDocTree(a));
}
}
if (MapUtil.isNotEmpty(newAMap)) {
for (ArticleEntity a : newAMap.values()) {
allDoc.add(DocUtil.toDocTree(a));
}
}
}
// pid 分组, 依次校验各组下数据
Map<Long, List<DocTreeRes>> map = allDoc.stream().collect(Collectors.groupingBy(DocTreeRes::getP));
map.forEach((pid, list) -> {
// 获取不重复的排序
long distinct = list.stream().map(DocTreeRes::getS).distinct().count();
// 不重复的个数小于总数则有重复
if (distinct != list.size()) {
throw new XzException400("排序重复");
}
});
return true;
}
}

View File

@ -0,0 +1,69 @@
package com.blossom.backend.server.doc.pojo;
import com.blossom.backend.server.doc.DocTypeEnum;
import lombok.Data;
import java.util.List;
/**
* 修改文档排序
*
* @since 1.14.0
*/
@Data
public class DocTreeUpdSortReq {
/**
* 需要修改排序的文档列表
*/
private List<Doc> docs;
/**
* 文档类型 1:文章文件夹; 2:图片文件夹
*
* @see com.blossom.backend.server.folder.FolderTypeEnum
*/
private Integer folderType;
/**
* [Picture + Article] 只查询图片文件夹, 以及含有图片的文章文件夹
*/
private Boolean onlyPicture;
public Boolean getOnlyPicture() {
if (onlyPicture == null) {
return false;
}
return onlyPicture;
}
@Data
public static class Doc {
/**
* id
*/
private Long i;
/**
* 父id
*/
private Long p;
/**
* 名称, 文件夹名称或文章名称
*/
private String n;
/**
* 排序
*/
private Integer s;
/**
* 类型 {@link DocTypeEnum}
*
* @see com.blossom.backend.server.doc.DocTypeEnum
*/
private Integer ty;
/**
* 存储路径
*/
private String sp;
}
}

View File

@ -5,10 +5,9 @@ import cn.hutool.core.util.ObjUtil;
import com.blossom.backend.base.auth.AuthContext;
import com.blossom.backend.base.auth.annotation.AuthIgnore;
import com.blossom.backend.config.BlConstants;
import com.blossom.backend.server.article.draft.pojo.ArticleEntity;
import com.blossom.backend.server.article.draft.pojo.ArticleStarReq;
import com.blossom.backend.server.article.draft.pojo.ArticleUpdTagReq;
import com.blossom.backend.server.doc.DocService;
import com.blossom.backend.server.doc.DocSortChecker;
import com.blossom.backend.server.folder.pojo.*;
import com.blossom.backend.server.utils.DocUtil;
import com.blossom.common.base.exception.XzException404;
@ -34,6 +33,7 @@ import java.util.List;
public class FolderController {
private final FolderService baseService;
private final DocService docService;
private final DocSortChecker docSortChecker;
/**
* 查询专题列表 [OP]
@ -96,9 +96,9 @@ public class FolderController {
FolderEntity folder = req.to(FolderEntity.class);
folder.setTags(DocUtil.toTagStr(req.getTags()));
folder.setUserId(AuthContext.getUserId());
// 如果新增到顶部, 获取最小的
// 如果新增到底部, 获取最大的排序
if (BooleanUtil.isTrue(req.getAddToLast())) {
folder.setSort(docService.selectMinSortByPid(req.getPid()) + 1);
folder.setSort(docService.selectMaxSortByPid(req.getPid(), AuthContext.getUserId(), FolderTypeEnum.PICTURE) + 1);
}
return R.ok(baseService.insert(folder));
}
@ -112,6 +112,15 @@ public class FolderController {
public R<Long> update(@Validated @RequestBody FolderUpdReq req) {
FolderEntity folder = req.to(FolderEntity.class);
folder.setTags(DocUtil.toTagStr(req.getTags()));
// 检查排序是否重复
// if (folder.getSort() != null && folder.getPid() != null) {
// final long newPid = folder.getPid();
// docSortChecker.checkUnique(CollUtil.newArrayList(newPid),
// CollUtil.newArrayList(folder),
// null,
// FolderTypeEnum.ARTICLE,
// AuthContext.getUserId());
// }
return R.ok(baseService.update(folder));
}

View File

@ -5,17 +5,13 @@ import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.blossom.backend.server.article.TagEnum;
import com.blossom.backend.server.article.draft.ArticleService;
import com.blossom.backend.server.article.draft.ArticleMapper;
import com.blossom.backend.server.article.draft.pojo.ArticleEntity;
import com.blossom.backend.server.article.draft.pojo.ArticleQueryReq;
import com.blossom.backend.server.doc.pojo.DocTreeRes;
import com.blossom.backend.server.folder.pojo.FolderEntity;
import com.blossom.backend.server.folder.pojo.FolderQueryReq;
import com.blossom.backend.server.folder.pojo.FolderSubjectRes;
import com.blossom.backend.server.picture.PictureService;
import com.blossom.backend.server.picture.PictureMapper;
import com.blossom.backend.server.picture.pojo.PictureEntity;
import com.blossom.backend.server.utils.DocUtil;
import com.blossom.common.base.enums.YesNo;
import com.blossom.common.base.exception.XzException400;
import com.blossom.common.base.exception.XzException404;
import com.blossom.common.base.exception.XzException500;
@ -29,7 +25,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
@ -40,19 +35,12 @@ import java.util.stream.Collectors;
@Slf4j
@Service
public class FolderService extends ServiceImpl<FolderMapper, FolderEntity> {
private ArticleService articleService;
private PictureService pictureService;
@Autowired
public void setArticleService(ArticleService articleService) {
this.articleService = articleService;
}
private PictureMapper picMapper;
@Autowired
public void setPictureService(PictureService pictureService) {
this.pictureService = pictureService;
}
private ArticleMapper articleMapper;
/**
@ -69,9 +57,11 @@ public class FolderService extends ServiceImpl<FolderMapper, FolderEntity> {
FolderEntity where = new FolderEntity();
where.setTags(TagEnum.subject.name());
where.setUserId(userId);
if (Objects.nonNull(starStatus) &&
(starStatus.equals(1) || starStatus.equals(0))) { where.setStarStatus(starStatus); }
else {where.setStarStatus(0);}
if (null != starStatus && (starStatus.equals(1) || starStatus.equals(0))) {
where.setStarStatus(starStatus);
} else {
where.setStarStatus(0);
}
List<FolderEntity> allOpenSubject = baseMapper.listAll(where);
if (CollUtil.isEmpty(allOpenSubject)) {
return new ArrayList<>();
@ -85,12 +75,10 @@ public class FolderService extends ServiceImpl<FolderMapper, FolderEntity> {
allOpenSubjectIds.addAll(allOpenSubjectChildFolders.stream().map(FolderEntity::getId).collect(Collectors.toList()));
// 3. 查询这些文件夹下的所有文章
ArticleQueryReq articleWhere = new ArticleQueryReq();
ArticleEntity articleWhere = new ArticleEntity();
articleWhere.setPids(allOpenSubjectIds);
articleWhere.setUserId(userId);
// 统计专题信息时, 会包含非公开文章
// articleWhere.setOpenStatus(YesNo.YES.getValue());
List<ArticleEntity> articles = articleService.listAll(articleWhere);
List<ArticleEntity> articles = articleMapper.listAll(articleWhere);
List<FolderSubjectRes> results = new ArrayList<>();
@ -123,34 +111,6 @@ public class FolderService extends ServiceImpl<FolderMapper, FolderEntity> {
return results;
}
/**
* 查询全部文件夹, 并转换成 {@link DocTreeRes}
*/
public List<DocTreeRes> listTree(FolderQueryReq req) {
List<FolderEntity> folders = baseMapper.listAll(req.to(FolderEntity.class));
return DocUtil.toTreeRes(folders);
}
/**
* 递归获取传入ID的所有的父文件夹, 并转换成 {@link DocTreeRes}, 结果会包含自己
*
* @param ids ID 集合
*/
public List<DocTreeRes> recursiveToParentTree(List<Long> ids) {
List<FolderEntity> folders = baseMapper.recursiveToParent(ids);
return DocUtil.toTreeRes(folders);
}
/**
* 递归获取传入ID的所有的子文件夹, 并转换成 {@link DocTreeRes}, 结果会包含自己
*
* @param ids ID 集合
*/
public List<DocTreeRes> recursiveToChildrenTree(List<Long> ids) {
List<FolderEntity> folders = baseMapper.recursiveToChildren(ids);
return DocUtil.toTreeRes(folders);
}
/**
* 根据ID查询
*
@ -200,9 +160,52 @@ public class FolderService extends ServiceImpl<FolderMapper, FolderEntity> {
*/
@Transactional(rollbackFor = Exception.class)
public Long update(FolderEntity folder) {
updateParamValid(folder);
updateStorePath(folder);
baseMapper.updById(folder);
return folder.getId();
}
/**
* 删除文件夹
* <p>1. 文件夹下有子文件夹时, 无法删除</p>
* <p>2. 文件夹下有文章时, 无法删除</p>
*
* @param folderId 文件夹ID
*/
@Transactional(rollbackFor = Exception.class)
public void delete(Long folderId) {
// 文件夹下有文件夹, 无法删除
if (baseMapper.recursiveToChildren(CollUtil.newArrayList(folderId)).stream().anyMatch(d -> !d.getId().equals(folderId))) {
throw new XzException500("文件夹下有子文件夹, 无法删除, 请先删除子文件夹");
}
// 文件夹下有文章, 无法删除
ArticleEntity articleWhere = new ArticleEntity();
articleWhere.setPids(CollUtil.newArrayList(folderId));
if (CollUtil.isNotEmpty(articleMapper.listAll(articleWhere))) {
throw new XzException500("文件夹下有文章, 无法删除, 请先删除下属文章");
}
// 文件夹下有图片, 无法删除
PictureEntity picReq = new PictureEntity();
picReq.setPid(folderId);
if (CollUtil.isNotEmpty(picMapper.listAll(picReq))) {
throw new XzException500("文件夹下有图片, 无法删除, 请先删除下属图片");
}
baseMapper.deleteById(folderId);
}
/**
* 修改文件夹的存储地址
*
* @param folder
*/
private void updateStorePath(FolderEntity folder) {
// 处理文件夹的存储地址
XzException404.throwBy(folder.getId() == null, "ID不得为空");
XzException400.throwBy(folder.getId().equals(folder.getPid()), "上级文件夹不能是自己");
if (Objects.isNull(folder.getStarStatus())) {folder.setStarStatus(0);}
// 如果
if (StrUtil.isNotBlank(folder.getStorePath())) {
final FolderEntity oldFolder = selectById(folder.getId());
@ -221,9 +224,6 @@ public class FolderService extends ServiceImpl<FolderMapper, FolderEntity> {
}
}
folder.setStorePath(formatStorePath(folder.getStorePath()));
if (Objects.isNull(folder.getStarStatus())) { folder.setStarStatus(0);}
baseMapper.updById(folder);
return folder.getId();
}
/**
@ -253,34 +253,12 @@ public class FolderService extends ServiceImpl<FolderMapper, FolderEntity> {
}
/**
* 删除文件夹
* <p>1. 文件夹下有子文件夹时, 无法删除</p>
* <p>2. 文件夹下有文章时, 无法删除</p>
* 检查修改是否有效
*
* @param folderId 文件夹ID
* @param folder 文件夹
*/
@Transactional(rollbackFor = Exception.class)
public void delete(Long folderId) {
// 文件夹下有文件夹, 无法删除
if (recursiveToChildrenTree(CollUtil.newArrayList(folderId)).stream().anyMatch(d -> !d.getI().equals(folderId))) {
throw new XzException500("文件夹下有子文件夹, 无法删除, 请先删除子文件夹");
}
// 文件夹下有文章, 无法删除
ArticleQueryReq articleReq = new ArticleQueryReq();
articleReq.setPids(CollUtil.newArrayList(folderId));
if (CollUtil.isNotEmpty(articleService.listTree(articleReq))) {
throw new XzException500("文件夹下有文章, 无法删除, 请先删除下属文章");
}
// 文件夹下有图片, 无法删除
PictureEntity picReq = new PictureEntity();
picReq.setPid(folderId);
if (CollUtil.isNotEmpty(pictureService.listAll(picReq))) {
throw new XzException500("文件夹下有图片, 无法删除, 请先删除下属图片");
}
baseMapper.deleteById(folderId);
private void updateParamValid(FolderEntity folder) {
XzException404.throwBy(folder.getId() == null, "ID不得为空");
XzException400.throwBy(folder.getId().equals(folder.getPid()), "上级文件夹不能是自己");
}
}

View File

@ -28,4 +28,14 @@ public enum FolderTypeEnum {
this.type = type;
this.desc = desc;
}
public static FolderTypeEnum getType(Integer type) {
for (FolderTypeEnum value : FolderTypeEnum.values()) {
if (value.getType().equals(type)) {
return value;
}
}
return null;
}
}

View File

@ -1,13 +1,10 @@
package com.blossom.backend.server.folder.pojo;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.blossom.backend.server.folder.FolderTypeEnum;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.Date;
@ -18,7 +15,6 @@ import java.util.List;
*
* @author xzzz
*/
@EqualsAndHashCode(callSuper = true)
@Data
@TableName("blossom_folder")
public class FolderEntity extends AbstractPOJO implements Serializable {
@ -106,6 +102,26 @@ public class FolderEntity extends AbstractPOJO implements Serializable {
@TableField(exist = false)
private List<Long> ids;
/**
* 父ID集合
*/
@TableField(exist = false)
private List<Long> pids;
//endregion
@Override
public int hashCode() {
return Long.hashCode(this.getId());
}
@Override
public boolean equals(Object obj) {
if (obj instanceof FolderEntity) {
return this.id.equals(((FolderEntity)obj).getId());
}
return false;
}
}

View File

@ -122,7 +122,7 @@ public class DocUtil {
tree.setTy(DocTypeEnum.A.getType());
// 判断文章的版本与公开版本是否有差异
if (article.getOpenStatus().equals(YesNo.YES.getValue()) && article.getVersion() > article.getOpenVersion()) {
if (YesNo.YES.getValue().equals(article.getOpenStatus()) && article.getVersion() > article.getOpenVersion()) {
tree.setVd(YesNo.YES.getValue());
}

View File

@ -1,7 +1,7 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/blossom?useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true&allowMultiQueries=true&useSSL=false&&serverTimezone=GMT%2B8
url: jdbc:mysql://192.168.31.99:3306/blossom?useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true&allowMultiQueries=true&useSSL=false&&serverTimezone=GMT%2B8
username: root
password: jasmine888
hikari:
@ -9,7 +9,7 @@ spring:
logging:
level:
com.blossom: debug
com.blossom: info
com.blossom.expand.tracker: info
com.blossom.backend.base.auth: info
org.springframework.boot.web.embedded.tomcat.TomcatWebServer: warn

View File

@ -5,9 +5,9 @@
<select id="selectMaxSortByPid" resultType="java.lang.Integer">
select IFNULL(max(h.sort), 0)
from (
select sort from blossom_article where pid = #{pid}
select sort from blossom_article where pid = #{pid} and user_id = #{userId}
union
select sort from blossom_folder where pid = #{pid}
select sort from blossom_folder where pid = #{pid} and user_id = #{userId} and type = #{type}
) h;
</select>
</mapper>

View File

@ -26,6 +26,10 @@
<if test="openStatus != null ">and open_status = #{openStatus}</if>
<if test="type != null ">and type = #{type}</if>
<if test="tags != null and tags != ''">and tags like concat('%',#{tags},'%')</if>
<if test="pid != null">and pid = #{pid}</if>
<if test="pids != null and pids.size() != 0">and pid in
<foreach collection="pids" item="item" open="(" close=")" separator=",">#{item}</foreach>
</if>
<if test="userId != null">and user_id = #{userId}</if>
</where>
</select>

View File

@ -10,7 +10,7 @@
<script setup lang="ts">
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>
<style lang="scss">

View File

@ -94,6 +94,15 @@ export const docTreeApi = (params?: object): Promise<R<any>> => {
return rq.get<R<any>>('/doc/trees', { params })
}
/**
*
* @param data
* @returns
*/
export const docUpdSortApi = (data: object): Promise<R<any>> => {
return rq.post<R<any>>('/doc/upd/sort', data)
}
//#endregion
//#region ====================================================< folder >====================================================
@ -440,16 +449,6 @@ export const articleBackupListApi = (): Promise<R<BackupFile[]>> => {
return rq.get<BackupFile[]>('/article/backup/list')
}
/**
*
* @param params
* @returns
*/
export const articleBackupDownloadApi = (params?: object): Promise<any> => {
let config = { params: params, responseType: 'blob' }
return rq.get('/article/backup/download', config)
}
/**
*
* @param data

View File

@ -90,7 +90,7 @@ export class Request {
/* 其他接口报错, 直接拒绝并提示错误信息 */
let errorResponse = data
errorResponse['url'] = res.config.url
Notify.error(data.msg, '请求失败')
Notify.error(data.msg, '处理失败')
return Promise.reject(res)
}
},
@ -106,22 +106,23 @@ export class Request {
}
let code = err.code
let resp = err.response
if (resp && resp.data) {
Notify.error(resp.data.msg, '请求失败')
return Promise.reject(err)
}
console.log("🚀 ~ Request ~ constructor ~ resp:123123123", resp)
if (code === 'ERR_NETWORK') {
Notify.error('网络错误,请检查您的网络是否通畅', '请求失败')
Notify.error('网络错误, 请检查您的网络是否通畅', '请求失败')
return Promise.reject(err)
}
if (err.request && err.request.status === 404) {
Notify.error('未找到您的请求, 请您检查服务器地址!', '请求失败(404)')
Notify.error('未找到您的请求', '请求失败(404)')
return Promise.reject(err)
}
if (err.request && err.request.status === 405) {
Notify.error(`您的请求地址可能有误, 请检查请求地址${url}`, '请求失败(405)')
return Promise.reject(err)
}
if (resp && resp.data) {
Notify.error(resp.data.msg, '请求失败')
return Promise.reject(err)
}
return Promise.reject(err)
}
)

View File

@ -12,4 +12,4 @@
@import './bl-tip.scss';
@import './bl-tooltip.scss';
@import './bl-tree.scss';
@import '../../views/doc/tree-docs-right-menu.scss';
@import '../../views/doc/doc-tree-right-menu.scss';

View File

@ -0,0 +1,9 @@
export const uuid = (): string => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = (Math.random() * 16) | 0,
v = c == 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
console.log(uuid())

View File

@ -433,3 +433,11 @@ export const isBase64Img = (image: string) => {
let prefix = image.substring(0, Math.max(image.indexOf(','), 0))
return prefix.startsWith('data:image') && prefix.endsWith('base64')
}
export const uuid = (): string => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = (Math.random() * 16) | 0,
v = c == 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}

View File

@ -127,6 +127,9 @@ export const useUserStore = defineStore('userStore', {
userinfo: (Local.get(userinfoKey) as Userinfo) || initUserinfo()
}),
getters: {
currentUserId(state) {
return state.userinfo.id
},
/**
*
*/

View File

@ -8,14 +8,14 @@
<div class="content">
<el-upload
multiple
class="article-upload"
ref="uploadRef"
name="file"
multiple
:action="serverStore.serverUrl + articleImportApiUrl"
:data="{ pid: porps.doc.i }"
:data="{ pid: porps.doc.i, batchId: importBatch }"
:headers="{ Authorization: 'Bearer ' + userStore.auth.token }"
:on-change="onChange"
:show-file-list="true"
:before-upload="beforeUpload"
:on-success="onUploadSeccess"
:on-error="onError"
@ -38,6 +38,7 @@ import type { UploadInstance } from 'element-plus'
import { articleImportApiUrl } from '@renderer/api/blossom'
import { useUserStore } from '@renderer/stores/user'
import { useServerStore } from '@renderer/stores/server'
import { uuid } from '@renderer/assets/utils/util'
import { onChange, beforeUpload, onUploadSeccess, onError } from './scripts/article-import'
const userStore = useUserStore()
@ -50,6 +51,8 @@ const porps = defineProps({
}
})
const importBatch = ref(uuid())
const uploadRef = ref<UploadInstance>()
const submitUpload = () => {
@ -66,21 +69,21 @@ const submitUpload = () => {
.content {
padding: 20px;
.upload-tip {
border: 1px solid var(--el-border-color);
padding: 5px 10px;
border-radius: 5px;
color: rgb(188, 55, 55);
}
// .upload-tip {
// border: 1px solid var(--el-border-color);
// padding: 5px 10px;
// border-radius: 5px;
// color: rgb(188, 55, 55);
// }
.article-upload {
:deep(.el-upload-list) {
li {
transition: none;
margin-bottom: 0;
}
}
}
// .article-upload {
// :deep(.el-upload-list) {
// li {
// transition: none;
// margin-bottom: 0;
// }
// }
// }
}
}
</style>

View File

@ -222,7 +222,7 @@ import { ref, nextTick, inject, computed, watch, Ref } from 'vue'
import { ElInput, ElMessageBox, FormInstance } from 'element-plus'
import type { FormRules } from 'element-plus'
import { Document } from '@element-plus/icons-vue'
import { provideKeyDocTree, getCDocsByPid, getDocById, checkLevel } from '@renderer/views/doc/doc'
import { provideKeyDocTree, getCDocsByPid, getDocById } from '@renderer/views/doc/doc'
import { useUserStore } from '@renderer/stores/user'
import {
folderInfoApi,
@ -388,6 +388,10 @@ const formatStorePath = () => {
}
const showStorePathWarning = ref(false)
/**
* 填充文件夹路径
*/
const fillStorePath = (id: string, path: string = ''): void => {
let doc = getDocById(id, docTreeData!.value)
if (!doc) {
@ -542,13 +546,9 @@ const saveDoc = async (formEl: FormInstance | undefined) => {
await formEl.validate((valid, _fields) => {
if (valid) {
saveLoading.value = true
if (!checkLevel(docForm.value.pid, docTreeData!.value)) {
saveLoading.value = false
return
}
const handleResp = (_: any) => {
Notify.success(curDocDialogType.value === 'upd' ? `修改《${docForm.value.name}》成功` : `新增《${docForm.value.name}》成功`)
emits('saved', curDocDialogType.value)
emits('saved', curDocDialogType.value, docForm.value)
}
const handleFinally = () => setTimeout(() => (saveLoading.value = false), 300)
//

View File

@ -1,6 +1,6 @@
<template>
<div :class="[viewStyle.isShowSubjectStyle ? (isSubject ? 'subject-title' : 'doc-title') : 'doc-title']">
<bl-tag class="sort" v-show="props.trees.showSort" :bgColor="levelColor">
<bl-tag class="sort" v-show="props.trees.showSort">
{{ props.trees.s }}
</bl-tag>
<div class="doc-name">
@ -30,9 +30,6 @@
{{ tag.content }}
</bl-tag>
</div>
<div v-if="level >= 2" class="folder-level-line" style="left: -20px"></div>
<div v-if="level >= 3" class="folder-level-line" style="left: -30px"></div>
<div v-if="level >= 4" class="folder-level-line" style="left: -40px"></div>
<div
v-if="viewStyle.isShowArticleType"
v-for="(line, index) in tagLins"
@ -47,19 +44,13 @@ import { computed } from 'vue'
import type { PropType } from 'vue'
import { useConfigStore } from '@renderer/stores/config'
import { isNotBlank } from '@renderer/assets/utils/obj'
import { computedDocTitleColor } from '@renderer/views/doc/doc'
import { articleUpdNameApi, folderUpdNameApi } from '@renderer/api/blossom'
const { viewStyle } = useConfigStore()
const props = defineProps({
trees: { type: Object as PropType<DocTree>, default: {} },
size: { type: Number, default: 14 },
level: { type: Number, required: true }
})
const levelColor = computed(() => {
return computedDocTitleColor(props.level)
size: { type: Number, default: 14 }
})
const nameWrapperStyle = computed(() => {
@ -137,8 +128,8 @@ $icon-size: 17px;
.doc-title {
@include flex(row, flex-start, flex-start);
width: 100%;
padding-bottom: 1px;
position: relative;
padding: 1px 0;
.doc-name {
@include flex(row, flex-start, flex-start);
@ -147,12 +138,13 @@ $icon-size: 17px;
font-size: inherit;
align-content: flex-start;
flex-wrap: wrap;
height: 100%;
width: 100%;
.menu-icon,
.menu-icon-img {
@include box($icon-size, $icon-size, $icon-size, $icon-size, $icon-size, $icon-size);
margin-top: 5px;
margin-top: 4px;
margin-right: 8px;
}
.menu-icon-img {
@ -160,7 +152,8 @@ $icon-size: 17px;
}
.name-wrapper {
@include ellipsis();
min-height: 23px;
line-height: 23px;
}
}
@ -171,27 +164,18 @@ $icon-size: 17px;
top: 2px;
z-index: 10;
}
.folder-level-line {
@include box(1px, 100%);
}
}
.folder-level-line {
background-color: var(--el-border-color);
position: absolute;
opacity: 0;
transition: opacity 0.5s;
}
// ,
.subject-title {
@include flex(row, flex-start, flex-start);
@include themeShadow(2px 2px 10px 1px var(--el-color-primary-light-8), 2px 2px 10px 1px #131313);
@include themeShadow(2px 2px 8px 1px var(--el-color-primary-light-8), 2px 2px 10px 1px #131313);
background: linear-gradient(135deg, var(--el-color-primary-light-7), var(--el-color-primary-light-8), var(--bl-html-color));
max-width: calc(100% - 15px);
min-width: calc(100% - 15px);
padding: 2px 5px;
margin: 5px 0 10px 0;
min-height: 44px;
max-width: 300px;
width: calc(100% - 10px);
padding: 4px 5px;
margin: 5px 0 5px 0;
border-radius: 7px;
position: relative;
@ -206,7 +190,6 @@ $icon-size: 17px;
.menu-icon,
.menu-icon-img {
@include box($icon-size, $icon-size, $icon-size, $icon-size, $icon-size, $icon-size);
margin-top: 5px;
margin-right: 8px;
}
@ -215,7 +198,6 @@ $icon-size: 17px;
}
.name-wrapper {
@include ellipsis();
max-width: calc(100% - 25px);
min-width: calc(100% - 25px);
}
@ -223,13 +205,9 @@ $icon-size: 17px;
.sort {
position: absolute;
right: -15px;
right: -10px;
}
.folder-level-line {
@include box(1px, calc(100% + 15px));
top: -5px;
}
}
//
@ -238,8 +216,8 @@ $icon-size: 17px;
.sync-line {
position: absolute;
width: 2px;
height: 60%;
top: 20%;
height: 70%;
top: 15%;
border-radius: 10px;
}

View File

@ -1,154 +1,26 @@
<template>
<div class="doc-workbench-root">
<bl-col class="workbench-name" just="flex-start" align="flex-end" height="46px" v-show="curArticle !== undefined">
<span>{{ curArticle?.name }}</span>
<span style="font-size: 9px; padding-right: 5px">{{ curArticle?.id }}</span>
</bl-col>
<bl-row class="wb-page-container">
<bl-row class="wb-page-item" just="flex-start" align="flex-end" width="calc(100% - 16px)" height="44px">
<el-tooltip
content="文章引用网络"
effect="light"
popper-class="is-small"
transition="none"
placement="top"
:show-arrow="false"
:offset="-5"
:hide-after="0">
<bl-row class="wb-page-item" just="flex-start" align="flex-end" height="44px">
<el-tooltip content="文章引用网络" effect="light" popper-class="is-small" placement="top" :offset="-5" :hide-after="0">
<div class="iconbl bl-correlation-line" @click="openArticleReferenceWindow()"></div>
</el-tooltip>
<el-tooltip
content="新增文件夹或文章"
effect="light"
popper-class="is-small"
transition="none"
placement="top"
:show-arrow="false"
:offset="8"
:hide-after="0">
<div class="iconbl bl-a-fileadd-line" @click="handleShowAddDocInfoDialog()"></div>
</el-tooltip>
<el-tooltip
content="刷新"
effect="light"
popper-class="is-small"
transition="none"
placement="top"
:show-arrow="false"
:offset="8"
:hide-after="0">
<div class="iconbl bl-a-cloudrefresh-line" @click="refreshDocTree()"></div>
</el-tooltip>
<el-tooltip
content="全文搜索"
effect="light"
popper-class="is-small"
transition="none"
placement="top"
:show-arrow="false"
:offset="9"
:hide-after="0">
<el-tooltip content="全文搜索" effect="light" popper-class="is-small" placement="top" :offset="9" :hide-after="0">
<div class="iconbl bl-search-line" @click="showSearch()"></div>
</el-tooltip>
<el-tooltip effect="light" popper-class="is-small" transition="none" placement="top" :show-arrow="false" :offset="5" :hide-after="0">
<div class="iconbl bl-a-leftdirection-line" @click="emits('show-sort')"></div>
<template #content>
显示排序<br />
<bl-row>
<bl-tag :bgColor="SortLevelColor.ONE">一级</bl-tag>
<bl-tag :bgColor="SortLevelColor.TWO">二级</bl-tag>
</bl-row>
<bl-row style="padding-bottom: 5px">
<bl-tag :bgColor="SortLevelColor.THREE">三级</bl-tag>
<bl-tag :bgColor="SortLevelColor.FOUR">四级</bl-tag>
</bl-row>
</template>
</el-tooltip>
<el-tooltip
content="备份记录"
effect="light"
popper-class="is-small"
transition="none"
placement="top"
:show-arrow="false"
:offset="8"
:hide-after="0">
<el-tooltip content="备份记录" effect="light" popper-class="is-small" placement="top" :offset="8" :hide-after="0">
<div class="iconbl bl-a-cloudstorage-line" @click="handleShowBackupDialog"></div>
</el-tooltip>
<el-tooltip
content="文章回收站"
effect="light"
popper-class="is-small"
transition="none"
placement="top"
:show-arrow="false"
:offset="8"
:hide-after="0">
<el-tooltip content="文章回收站" effect="light" popper-class="is-small" placement="top" :offset="8" :hide-after="0">
<div class="iconbl bl-delete-line" @click="handleShowRecycleDialog"></div>
</el-tooltip>
<el-tooltip
content="查看收藏"
effect="light"
popper-class="is-small"
transition="none"
placement="top"
:show-arrow="false"
:offset="8"
:hide-after="0">
<div v-if="props.showStar">
<div v-if="onlyStars" class="iconbl bl-star-fill" @click="changeOnlyStar()"></div>
<div v-else class="iconbl bl-star-line" @click="changeOnlyStar()"></div>
</div>
</el-tooltip>
<el-tooltip
content="查看专题"
effect="light"
popper-class="is-small"
transition="none"
placement="top"
:show-arrow="false"
:offset="8"
:hide-after="0">
<div v-if="props.showSubject">
<div v-if="onlySubject" class="iconbl bl-a-lowerrightpage-fill" @click="changeOnlySubject()"></div>
<div v-else class="iconbl bl-a-lowerrightpage-line" @click="changeOnlySubject()"></div>
</div>
</el-tooltip>
<el-tooltip
content="查看公开"
effect="light"
popper-class="is-small"
transition="none"
placement="top"
:show-arrow="false"
:offset="8"
:hide-after="0">
<div v-if="props.showOpen">
<div v-if="onlyOpen" class="iconbl bl-cloud-fill" @click="changeOnlyOpen()"></div>
<div v-else class="iconbl bl-cloud-line" @click="changeOnlyOpen()"></div>
</div>
<el-tooltip content="文档快速编辑" effect="light" popper-class="is-small" placement="top" :offset="8" :hide-after="0">
<div class="iconbl bl-article-line" @click=""></div>
</el-tooltip>
</bl-row>
<bl-col width="12px" height="30px" just="end" class="workbench-more" style="">
<div class="iconbl bl-a-morevertical-line" @click="showMoreMenu"></div>
</bl-col>
</bl-row>
</div>
<el-dialog
v-model="isShowDocInfoDialog"
width="535"
top="100px"
style="margin-left: 320px"
:append-to-body="true"
:destroy-on-close="true"
:close-on-click-modal="false"
draggable>
<ArticleInfo ref="ArticleInfoRef" @saved="savedCallback"></ArticleInfo>
</el-dialog>
<el-dialog
class="bl-dialog-fixed-body"
v-model="isShowBackupDialog"
@ -174,25 +46,13 @@
draggable>
<ArticleRecycle ref="ArticleRecycleRef"></ArticleRecycle>
</el-dialog>
<Teleport to="body">
<div v-show="moreMenu.show" class="tree-menu" :style="{ left: moreMenu.clientX + 'px', top: moreMenu.clientY + 'px', width: '120px' }">
<div class="menu-content" style="border: none">
<div @click="changeOnlyOpen"><span class="iconbl bl-cloud-line"></span>查看公开</div>
<div @click="changeOnlySubject"><span class="iconbl bl-a-lowerrightpage-line"></span>查看专题</div>
<div @click="changeOnlyStar"><span class="iconbl bl-star-line"></span>查看收藏</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, nextTick, inject, onDeactivated } from 'vue'
import { provideKeyCurArticleInfo, SortLevelColor } from '@renderer/views/doc/doc'
import { ref, nextTick, onDeactivated } from 'vue'
import { openNewArticleReferenceWindow } from '@renderer/assets/utils/electron'
import { useLifecycle } from '@renderer/scripts/lifecycle'
import hotkeys from 'hotkeys-js'
import ArticleInfo from './ArticleInfo.vue'
import ArticleBackup from './ArticleBackup.vue'
import ArticleRecycle from './ArticleRecycle.vue'
@ -205,43 +65,9 @@ onDeactivated(() => {
unbindKeys()
})
const props = defineProps({
showOpen: {
type: Boolean,
default: true
},
showSubject: {
type: Boolean,
default: true
},
showStar: {
type: Boolean,
default: true
}
})
//#region --------------------------------------------------< >--------------------------------------------------
const moreMenu = ref<RightMenu>({ show: false, clientX: 0, clientY: 0 })
/**
* 显示右键菜单
* @param doc 文档
* @param event 事件
*/
const showMoreMenu = (event: MouseEvent) => {
moreMenu.value.show = true
nextTick(() => {
let y = event.clientY
if (document.body.clientHeight - event.clientY < 50) {
y = event.clientY - 50
}
moreMenu.value = { show: true, clientX: event.clientX, clientY: y }
setTimeout(() => {
document.body.addEventListener('click', closeMoreMenu)
}, 100)
})
}
const closeMoreMenu = (event: MouseEvent) => {
if (event.target) {
let isPrevent = (event.target as HTMLElement).getAttribute('data-bl-prevet')
@ -257,36 +83,6 @@ const closeMoreMenu = (event: MouseEvent) => {
//#endregion
//#region --------------------------------------------------< >--------------------------------------------------
const curArticle = inject(provideKeyCurArticleInfo)
const onlyOpen = ref<boolean>(false) //
const onlySubject = ref<boolean>(false) //
const onlyStars = ref<boolean>(false) // star
const changeOnlyOpen = () => {
onlyOpen.value = !onlyOpen.value
onlySubject.value = false
onlyStars.value = false
refreshDocTree()
}
const changeOnlySubject = () => {
onlyOpen.value = false
onlySubject.value = !onlySubject.value
onlyStars.value = false
refreshDocTree()
}
const changeOnlyStar = () => {
onlyOpen.value = false
onlySubject.value = false
onlyStars.value = !onlyStars.value
refreshDocTree()
}
//#endregion
//#region --------------------------------------------------< >--------------------------------------------------
const ArticleInfoRef = ref()
const isShowDocInfoDialog = ref<boolean>(false)
@ -302,23 +98,6 @@ const openArticleReferenceWindow = () => {
openNewArticleReferenceWindow()
}
/**
* 控制台刷新文档列表
*/
const refreshDocTree = () => {
emits('refreshDocTree', onlyOpen.value, onlySubject.value, onlyStars.value)
}
/**
* 保存后的回调
* 1. 刷新菜单列表
* 2. 关闭 dialog 页面
*/
const savedCallback = () => {
isShowDocInfoDialog.value = false
emits('refreshDocTree', onlyOpen.value, onlySubject.value, onlyStars.value)
}
const showSearch = () => {
emits('show-search')
}
@ -363,7 +142,7 @@ const unbindKeys = () => {
}
//#endregion
const emits = defineEmits(['refreshDocTree', 'show-sort', 'show-search'])
const emits = defineEmits(['show-sort', 'show-search'])
defineExpose({ handleShowBackupDialog })
</script>
@ -387,14 +166,6 @@ defineExpose({ handleShowBackupDialog })
height: 0px;
}
//
.bl-a-leftdirection-line {
font-size: 27px;
padding-bottom: 3px;
padding-right: 0;
padding-left: 0;
}
//
.bl-search-line {
font-size: 23px;
@ -408,14 +179,6 @@ defineExpose({ handleShowBackupDialog })
padding-bottom: 5px;
}
//
.bl-a-cloudrefresh-line,
.bl-a-fileadd-line {
&:active {
color: #ffffff;
}
}
.bl-correlation-line {
font-size: 40px;
padding-bottom: 0px;

View File

@ -1,21 +1,12 @@
<template>
<div class="editor-status-root">
<bl-row width="calc(100% - 240px)" height="100%" class="status-item-container">
<div>
{{ curDoc?.name }}
</div>
<div>
版本:{{ curDoc?.version }}
</div>
<div>
字数:{{ curDoc?.words }}
</div>
<div>
最近修改:{{ curDoc?.updTime }}
</div>
<div v-if="curDoc?.openTime">
发布:{{ curDoc?.openTime }}
</div>
<div>{{ curDoc?.name }}</div>
<div>ID:{{ curDoc?.id }}</div>
<div>版本:{{ curDoc?.version }}</div>
<div>字数:{{ curDoc?.words }}</div>
<div>最近修改:{{ curDoc?.updTime }}</div>
<div v-if="curDoc?.openTime">发布:{{ curDoc?.openTime }}</div>
</bl-row>
<bl-row just="flex-end" width="240px" height="100%" class="status-item-container">
<div @click="openArticleLogWindow">
@ -26,9 +17,7 @@
<span class="iconbl bl-correlation-line"></span>
引用网络
</div>
<bl-col width="100px" just="center">
渲染用时: {{ props.renderInterval }}ms
</bl-col>
<bl-col width="100px" just="center"> 渲染用时: {{ props.renderInterval }}ms </bl-col>
</bl-row>
</div>
</template>
@ -37,7 +26,7 @@
import { inject, toRaw } from 'vue'
import type { Ref } from 'vue'
import { provideKeyCurArticleInfo } from '@renderer/views/doc/doc'
import { openNewArticleReferenceWindow, openNewArticleLogWindow } from "@renderer/assets/utils/electron"
import { openNewArticleReferenceWindow, openNewArticleLogWindow } from '@renderer/assets/utils/electron'
const props = defineProps({
renderInterval: {
@ -73,7 +62,7 @@ const openArticleLogWindow = () => {
overflow-x: overlay;
white-space: nowrap;
&>div {
& > div {
height: 100%;
padding: 0 5px;
cursor: pointer;
@ -96,4 +85,4 @@ const openArticleLogWindow = () => {
@include flex(row, flex-start, center);
}
}
</style>
</style>

View File

@ -378,7 +378,6 @@ const handleShowHotKeyDialog = () => {
.editor-tools-root {
@include box(calc(100% - 20px), 35px);
@include themeShadow(0 1px 4px 1px #d3d3d3, 0 1px 4px 1px rgb(20, 20, 20));
margin: 5px 10px 10px 10px;
border-radius: 5px;
position: relative;
overflow: hidden;
@ -426,7 +425,7 @@ const handleShowHotKeyDialog = () => {
}
}
</style>
<!--
<!--
快捷键说明为弹出框 需要设置全局的样式
-->
<style lang="scss" scoped>

View File

@ -5,9 +5,9 @@ import Notify from '@renderer/scripts/notify'
* , :
* .txt
* .md
*
* @param _file
* @param files
*
* @param _file
* @param files
*/
export const onChange: UploadProps['onChange'] = (_file: UploadUserFile, files: UploadUserFile[]) => {
for (let i = 0; i < files.length; i++) {
@ -19,11 +19,10 @@ export const onChange: UploadProps['onChange'] = (_file: UploadUserFile, files:
}
}
/**
*
* @param rawFile
* @returns
*
* @param rawFile
* @returns
*/
export const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
if (rawFile.size / 1024 / 1024 > 10) {
@ -34,14 +33,19 @@ export const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
}
/**
*
* @param resp
* @param _file
* , http , , 20000 ,
* 'ready' | 'uploading' | 'success' | 'fail'
*
* @param resp
* @param _file
*/
export const onUploadSeccess: UploadProps['onSuccess'] = (resp, _file: UploadFile, _files: UploadFiles) => {
const result = handleUploadSeccess(resp)
console.log(_file)
if (result) {
_file.status = 'ready'
_file.status = 'success'
} else {
_file.status = 'fail'
}
}
@ -52,7 +56,7 @@ export const onUploadSeccess: UploadProps['onSuccess'] = (resp, _file: UploadFil
*/
export const handleUploadSeccess = (resp: any): boolean => {
if (resp.code === '20000') {
Notify.success('上传成功')
// Notify.success('上传成功')
return true
} else {
Notify.error(resp.msg, '上传失败')
@ -61,23 +65,24 @@ export const handleUploadSeccess = (resp: any): boolean => {
}
/**
*
* @param error
* @param _file
* @param _files
*
* @param error
* @param _file
* @param _files
*/
export const onError: UploadProps['onError'] = (error, _file, _files) => {
handleUploadError(error)
}
/**
*
* @param error
* , http ,
* @param error
*/
export const handleUploadError = (error: Error) => {
console.log('🚀 ~ handleUploadError ~ error:', error)
if (error.message != undefined) {
try {
let resp = JSON.parse(error.message);
let resp = JSON.parse(error.message)
if (resp != undefined) {
Notify.error(resp.msg, '上传失败')
}
@ -85,4 +90,4 @@ export const handleUploadError = (error: Error) => {
Notify.error(error.message, '上传失败')
}
}
}
}

View File

@ -94,4 +94,27 @@ export const parseTocAsync = async (ele: HTMLElement): Promise<Toc[]> => {
tocs.push(toc)
}
return tocs
}
}
/**
*
*
* @param resp
*/
export const downloadTextPlain = (resp: any) => {
let filename: string = resp.headers.get('content-disposition')
let filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
let matches = filenameRegex.exec(filename)
if (matches != null && matches[1]) {
filename = decodeURI(matches[1].replace(/['"]/g, ''))
}
filename = decodeURI(filename)
let a = document.createElement('a')
let blob = new Blob([resp.data], { type: 'text/plain' })
let objectUrl = URL.createObjectURL(blob)
a.setAttribute('href', objectUrl)
a.setAttribute('download', filename)
a.click()
URL.revokeObjectURL(a.href)
a.remove()
}

View File

@ -93,18 +93,19 @@
position: relative;
background-color: #ffffff00;
$heightTools: 45px;
$heightTools: 50px;
$heightStatus: 28px;
$heightEP: calc(100% - 5px - #{$heightStatus} - #{$heightTools});
$heightEP: calc(100% - #{$heightStatus} - #{$heightTools});
.editor-tools {
@include box(100%, $heightTools);
padding: 5px 10px 10px 10px;
border-bottom: 1px solid var(--el-border-color);
}
.editor-preview {
@include box(100%, $heightEP);
@include flex(row, flex-start, center);
border-top: 1px solid var(--el-border-color);
position: relative;
font-family: inherit;
font-size: inherit;
@ -137,6 +138,11 @@
overflow: overlay;
z-index: 2;
/* 定义滑块 内阴影+圆角 */
&::-webkit-scrollbar-thumb {
border-radius: 0;
}
:deep(*) {
font-family: inherit;
font-size: inherit;
@ -234,6 +240,11 @@
font-family: inherit;
font-size: inherit;
/* 定义滑块 内阴影+圆角 */
&::-webkit-scrollbar-thumb {
border-radius: 0;
}
:deep(.katex > *) {
font-size: 1.2em !important;
font-family: 'KaTeX_Size1', sans-serif !important;

View File

@ -0,0 +1,107 @@
$icon-size: 17px;
.doc-title {
@include flex(row, flex-start, flex-start);
width: 100%;
position: relative;
padding: 1px 0;
.doc-name {
@include flex(row, flex-start, flex-start);
@include themeBrightness(100%, 90%);
@include ellipsis();
font-size: inherit;
align-content: flex-start;
flex-wrap: wrap;
height: 100%;
width: 100%;
.menu-icon,
.menu-icon-img {
@include box($icon-size, $icon-size, $icon-size, $icon-size, $icon-size, $icon-size);
margin-top: 4px;
margin-right: 8px;
}
.menu-icon-img {
border-radius: 2px;
}
.name-wrapper {
min-height: 23px;
line-height: 23px;
}
}
.sort {
position: absolute;
padding: 0 2px;
right: 2px;
top: 2px;
z-index: 10;
}
}
// 专题样式, 包括边框和文字样式
.subject-title {
@include flex(row, flex-start, flex-start);
@include themeShadow(2px 2px 8px 1px var(--el-color-primary-light-8), 2px 2px 10px 1px #131313);
background: linear-gradient(135deg, var(--el-color-primary-light-7), var(--el-color-primary-light-8), var(--bl-html-color));
min-height: 44px;
max-width: 300px;
width: calc(100% - 10px);
padding: 4px 5px;
margin: 5px 0 5px 0;
border-radius: 7px;
.doc-name {
@include flex(row, flex-start, flex-start);
@include themeBrightness(100%, 100%);
color: var(--el-color-primary);
align-content: flex-start;
flex-wrap: wrap;
width: 100%;
.menu-icon,
.menu-icon-img {
@include box($icon-size, $icon-size, $icon-size, $icon-size, $icon-size, $icon-size);
margin-right: 8px;
}
.menu-icon-img {
border-radius: 2px;
}
.name-wrapper {
max-width: calc(100% - 25px);
min-width: calc(100% - 25px);
}
}
.sort {
position: absolute;
right: 1px;
}
}
// 在左侧显示
.open-line,
.star-line,
.sync-line {
position: absolute;
width: 2px;
height: 70%;
top: 15%;
border-radius: 10px;
}
.star-line {
@include themeBg(rgb(237, 204, 11), rgba(228, 195, 5, 0.724));
}
.open-line {
background: #79c20c71;
}
.sync-line {
background: #e8122479;
}

View File

@ -0,0 +1,68 @@
/* ======================================================================
*
* ====================================================================== */
import { isNotBlank } from '@renderer/assets/utils/obj'
/**
* , 便
* @param doc
* @param viewStyle
*/
export const tags = (doc: DocTree, viewStyle: { isShowArticleTocTag: boolean; isShowArticleCustomTag: boolean; isShowFolderOpenTag: boolean }) => {
let icons: any = []
doc.t.forEach((tag) => {
if (tag.toLocaleLowerCase() === 'subject') {
icons.unshift({ content: '专题', bgColor: 'var(--bl-tag-color-subject)', icon: 'bl-a-lowerrightpage-line' })
} else if (tag.toLocaleLowerCase() === 'toc') {
if (!viewStyle.isShowArticleTocTag) {
return
}
icons.push({ content: 'TOC', bgColor: 'var(--bl-tag-color-toc)' })
} else if (viewStyle.isShowArticleCustomTag) {
icons.push({ content: tag })
}
})
if (doc.o === 1 && doc.ty != 3) {
if (viewStyle.isShowFolderOpenTag) {
icons.unshift({ bgColor: 'var(--bl-tag-color-open)', icon: 'bl-cloud-line' })
}
}
return icons
}
/**
*
*/
export const tagLins = (doc: DocTree) => {
let lines: string[] = []
if (doc.star === 1) {
lines.push('star-line')
}
if (doc.o === 1 && doc.ty === 3) {
lines.push('open-line')
}
if (doc.vd === 1) {
lines.push('sync-line')
}
return lines
}
/**
*
* @param doc
* @param viewStyle
* @returns
*/
export const isShowImg = (doc: DocTree, viewStyle: { isShowArticleIcon: boolean }) => {
return viewStyle.isShowArticleIcon && isNotBlank(doc.icon) && (doc.icon.startsWith('http') || doc.icon.startsWith('https')) && !doc?.updn
}
/**
* svg
* @param doc
* @param viewStyle
* @returns
*/
export const isShowSvg = (doc: DocTree, viewStyle: { isShowArticleIcon: boolean }) => {
return viewStyle.isShowArticleIcon && isNotBlank(doc.icon) && !doc?.updn
}

View File

@ -0,0 +1,174 @@
.doc-workbench {
@include box(100%, 50px);
}
// 操作树状菜单的工具栏
.doc-tree-operator {
@include box(100%, 28px);
@include flex(row, center, center);
@include themeColor(#909399, #969696);
position: relative;
.iconbl {
font-size: 18px;
margin: 0 3px;
cursor: pointer;
&:hover {
color: var(--el-color-primary);
}
}
.bl-a-leftdirection-line {
font-size: 20px;
margin: 0 1px;
}
.bl-a-folderon-line {
margin: 0 5px;
}
.bl-refresh-line {
font-size: 16px;
margin: 0 2px;
&:active {
color: var(--el-color-primary-light-3);
}
}
.doc-tree-search {
position: absolute;
top: 28px;
right: 10px;
z-index: 2000;
.el-input {
border-radius: 4px;
background-color: var(--bl-html-color);
@include themeShadow(0 0 7px 1px #4a4a4a2f, 0 0 7px 1px #000000);
}
:deep(.el-input--suffix) {
* {
user-select: none;
}
}
:deep(.el-input-group__append) {
padding: 0;
:hover {
color: var(--el-color-primary);
}
div {
height: 24px;
width: 24px;
.el-icon {
height: 24px;
width: 24px;
}
}
}
}
}
.doc-trees-container {
@include box(100%, calc(100% - 78px));
overflow-y: scroll;
overflow-x: hidden;
* {
font-size: inherit;
}
.menu-item-wrapper {
width: 100%;
position: relative;
}
.doc-trees-placeholder {
width: 100%;
font-size: 13.5px;
text-align: right;
color: var(--bl-text-color-light);
padding-top: 10px;
.iconbl {
font-size: 16px;
margin: 0 4px;
cursor: pointer;
&:hover {
color: var(--el-color-primary);
}
}
}
.doc-tree {
@include box(100%, 100%);
padding-left: 5px;
// --el-transition-duration: .1s; // 折叠展开的动画效果
.menu-divider {
width: calc(100%);
height: 15px;
margin-left: -20px;
border-bottom: 1px solid var(--el-border-color);
margin-bottom: 15px;
pointer-events: none;
}
:deep(.menu-item-wrapper) {
width: 100%;
}
:deep(.el-tree-node__children) {
transition: none; // 关闭折叠展开动画
}
:deep(.is-drop-inner) {
box-shadow: inset 0 0 1px 2px var(--el-color-primary);
border-radius: 4px;
}
:deep(.el-tree__drop-indicator) {
height: 2px;
}
:deep(.el-tree-node__expand-icon) {
height: 10px;
width: 10px;
color: var(--bl-text-doctree-color);
transition: none;
}
:deep(.is-current) {
& > .el-tree-node__content {
border-radius: 5px;
// background-color: var(--el-color-primary-light-8);
&:has(.menu-divider) {
background-color: transparent;
}
}
}
:deep(.el-tree-node__content) {
height: auto;
&:hover {
border-radius: 5px;
}
}
}
&::-webkit-scrollbar {
width: 5px;
height: 3px;
}
/* 定义滑块 内阴影+圆角 */
&::-webkit-scrollbar-thumb {
border-radius: 0;
}
}

View File

@ -0,0 +1,241 @@
/* ======================================================================
*
* ====================================================================== */
import Node from 'element-plus/es/components/tree/src/model/node'
import { DragEvents } from 'element-plus/es/components/tree/src/model/useDragNode'
import { NodeDropType } from 'element-plus/es/components/tree/src/tree.type'
import { Ref } from 'vue'
export const getColor = (node: Node) => {
if (node.level === 1) {
return '#878787AF'
}
if (node.level === 2) {
return '#89A319AA'
}
if (node.level === 3) {
return '#A37E19AA'
}
if (node.level === 4) {
return '#A33B19AA'
}
if (node.level === 5) {
return '#19A383AA'
}
return
}
export interface NeedUpd {
i: string
p: string
s: number
n: string
ty: DocType
}
/**
*
* @param drag
* @param enter
* @param dropType
* @param _event
* @param DocTreeRef
* @param docTreeData
*/
export const handleTreeDrop = (
drag: Node,
enter: Node,
dropType: NodeDropType,
_event: DragEvents,
DocTreeRef: Ref,
docTreeData: Ref<DocTree[]>,
updateFn: (needUpd: NeedUpd[]) => void
) => {
// 是否同级别
const isSame = drag.data.p === enter.data.p
const dragSourceSort = drag.data.s
const enterSourceSort = enter.data.s
// 记录最终需要修改的数据
const needUpd: NeedUpd[] = []
// 保存需要传入后台修改的节点
const addUpd = (node: Node) => {
needUpd.push({
i: node.data.i,
p: node.data.p,
s: node.data.s,
n: node.data.n,
ty: node.data.ty
})
}
console.log(`same: ${isSame}, dropType: ${dropType}, drag: ${dragSourceSort}, enter: ${enterSourceSort}`)
if (isSame) {
// drag 在 enter 前
if (dropType === 'before') {
// 下方的拖动到上方
if (dragSourceSort > enterSourceSort) {
drag.data.s = enterSourceSort
addUpd(drag)
for (let i = 0; i < enter.parent.childNodes.length; i++) {
const node = enter.parent.childNodes[i]
if (node.data.s >= enterSourceSort && node.data.s < dragSourceSort && node.data.i != drag.data.i) {
node.data.s += 1
addUpd(node)
}
}
}
// 上方的拖动到下方
else if (isSame) {
drag.data.s = enterSourceSort - 1
addUpd(drag)
for (let i = 0; i < enter.parent.childNodes.length; i++) {
const node = enter.parent.childNodes[i]
if (node.data.s < enterSourceSort && node.data.s > dragSourceSort && node.data.i != drag.data.i) {
node.data.s -= 1
addUpd(node)
}
}
}
}
// drag 在 enter 后
else if (dropType === 'after') {
// 下方拖动到上方, 即拖拽的节点排序大于放置的节点排序
if (dragSourceSort > enterSourceSort) {
drag.data.s = enterSourceSort + 1
addUpd(drag)
for (let i = 0; i < enter.parent.childNodes.length; i++) {
const node = enter.parent.childNodes[i]
if (node.data.s > enterSourceSort && node.data.s < dragSourceSort && node.data.i != drag.data.i) {
node.data.s += 1
addUpd(node)
}
}
} else {
drag.data.s = enterSourceSort
addUpd(drag)
for (let i = 0; i < enter.parent.childNodes.length; i++) {
const node = enter.parent.childNodes[i]
if (node.data.s <= enterSourceSort && node.data.s > dragSourceSort && node.data.i != drag.data.i) {
node.data.s -= 1
addUpd(node)
}
}
}
}
// drag 在 enter 内部
if (dropType === 'inner') {
const dragSourcePid = drag.data.p
const dragSourceParent = DocTreeRef.value.getNode(dragSourcePid)
if (dragSourceParent) {
for (let i = 0; i < dragSourceParent.childNodes.length; i++) {
const node = dragSourceParent.childNodes[i]
if (node.data.s > dragSourceSort) {
node.data.s -= 1
addUpd(node)
}
}
}
drag.data.p = enter.data.i
let maxSort: number = 0
if (!enter.childNodes || enter.childNodes.length == 0) {
maxSort = 0
} else if (enter.childNodes.length == 1 && enter.childNodes[0].data.i === drag.data.i) {
maxSort = 0
} else {
maxSort = getMaxSort(drag, enter.childNodes)
}
drag.data.s = maxSort + 1
addUpd(drag)
}
}
// 不同的父级
else {
const dragSourcePid = drag.data.p
// pid 为 0 时需要特殊处理, 无法通过 node 获取, 只能通过 docTreeData 获取
if (dragSourcePid === '0') {
for (let i = 0; i < docTreeData.value.length; i++) {
const node = docTreeData.value[i]
if (node.s > dragSourceSort) {
node.s -= 1
needUpd.push({ i: node.i, p: node.p, n: node.n, s: node.s, ty: node.ty })
}
}
} else {
const dragSourceParent = DocTreeRef.value.getNode(dragSourcePid)
if (dragSourceParent) {
for (let i = 0; i < dragSourceParent.childNodes.length; i++) {
const node = dragSourceParent.childNodes[i]
if (node.data.s > dragSourceSort) {
node.data.s -= 1
addUpd(node)
}
}
}
}
if (dropType === 'before') {
drag.data.p = enter.data.p
drag.data.s = enterSourceSort
addUpd(drag)
for (let i = 0; i < enter.parent.childNodes.length; i++) {
const node = enter.parent.childNodes[i]
if (node.data.s >= enterSourceSort && node.data.i != drag.data.i) {
node.data.s += 1
addUpd(node)
}
}
}
if (dropType === 'after') {
drag.data.p = enter.data.p
drag.data.s = enterSourceSort + 1
addUpd(drag)
for (let i = 0; i < enter.parent.childNodes.length; i++) {
const node = enter.parent.childNodes[i]
if (node.data.s > enterSourceSort && node.data.i != drag.data.i) {
node.data.s += 1
addUpd(node)
}
}
}
if (dropType === 'inner') {
drag.data.p = enter.data.i
let maxSort: number = 0
if (!enter.childNodes || enter.childNodes.length == 0) {
maxSort = 0
} else if (enter.childNodes.length == 1 && enter.childNodes[0].data.i === drag.data.i) {
maxSort = 0
} else {
maxSort = getMaxSort(drag, enter.childNodes)
}
drag.data.s = maxSort + 1
addUpd(drag)
}
}
if (needUpd.length > 0) {
updateFn(needUpd)
}
console.log(needUpd)
console.log('==========================================')
}
/**
* nodes , drag
*/
export const getMaxSort = (drag: Node, nodes: Node[]): number => {
const maxSort = Math.max.apply(
Math,
nodes
.filter((n) => n.data.i != drag.data.i)
.map((item) => {
return item.data.s
})
)
return maxSort
}

View File

@ -75,13 +75,14 @@ export const computedDocTitleColor = (level: number) => {
* @param pid ID
* @param trees
* @returns
* @deprecated 1.14.0
*/
export const checkLevel = (pid: string, trees: DocTree[]): boolean => {
let parents = getPDocsByPid(pid, trees)
if (parents.length >= 4) {
Notify.error('最多仅支持4级层级关系', '菜单层级错误')
return false
}
// let parents = getPDocsByPid(pid, trees)
// if (parents.length >= 4) {
// Notify.error('最多仅支持4级层级关系', '菜单层级错误')
// return false
// }
return true
}

View File

@ -1,117 +0,0 @@
.doc-workbench {
@include box(100%, 90px);
}
.doc-trees-container {
@include box(100%, calc(100% - 90px));
// 图片文档菜单中需要有 padding-top
padding-top: 8px;
&:hover {
:deep(.folder-level-line) {
opacity: 1;
}
}
* {
font-size: inherit;
}
.menu-item-wrapper {
width: 100%;
}
.doc-trees-placeholder {
width: 100%;
padding-right: 35px;
color: var(--bl-text-color-light);
font-size: 13.5px;
text-align: right;
}
.doc-trees {
@include box(100%, 100%);
font-weight: 200;
border: 0;
overflow-y: overlay;
// 基础的 padding
--el-menu-base-level-padding: 25px;
// 每级别的的缩进
--el-menu-level-padding: 10px;
// ------------------- sub-item 的样式
// sub-item 菜单的高度
--el-menu-sub-item-height: 25px;
// ------------------- item 的样式
// 菜单每个 item 的高度
--el-menu-item-height: 25px;
--el-transition-duration: 0.1s;
--el-menu-hover-bg-color: #0000000b;
// item 字体大小
// --el-menu-item-font-size: inherit;
// --el-font-size-base: inherit;
.menu-divider {
width: 100%;
height: 15px;
border-bottom: 1px solid var(--el-border-color);
margin-bottom: 15px;
}
:deep(.el-sub-menu) {
.el-sub-menu__title {
height: auto;
min-height: 25px;
padding-right: 0;
overflow: hidden;
color: var(--bl-text-doctree-color);
white-space: nowrap;
text-overflow: ellipsis;
font-size: inherit;
.el-sub-menu__icon-arrow {
color: var(--bl-text-color-light);
left: calc(10px + (var(--el-menu-level) * 10px));
font-size: 12px;
width: 0.8em;
height: 0.8em;
}
}
}
.el-menu-item {
font-weight: 300;
color: var(--bl-text-doctree-color);
height: auto;
min-height: 25px;
padding-right: 0;
border-radius: 5px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: none;
}
:deep(.el-menu-item.is-active) {
@include themeText(0px 1px 3px rgba(107, 104, 104, 1), 0px 1px 3px rgb(145, 145, 145));
background: linear-gradient(
90deg,
var(--bl-html-color) 0%,
var(--el-color-primary-light-7) 40%,
var(--el-color-primary-light-7) 60%,
var(--bl-html-color) 100%
);
color: #ffffff;
font-weight: bold;
}
:deep(.el-badge__content) {
top: 7px;
transform: translateY(-50%) translateX(100%) scale(0.8);
}
}
::-webkit-scrollbar {
width: 5px;
height: 3px;
}
}

View File

@ -142,7 +142,7 @@ import { ref, nextTick, inject, computed, watch, Ref } from 'vue'
import { ElInput } from 'element-plus'
import type { FormRules } from 'element-plus'
import { Document } from '@element-plus/icons-vue'
import { checkLevel, getCDocsByPid, provideKeyDocTree, getDocById } from '@renderer/views/doc/doc'
import { getCDocsByPid, provideKeyDocTree, getDocById } from '@renderer/views/doc/doc'
import { useUserStore } from '@renderer/stores/user'
import { folderInfoApi, folderAddApi, folderUpdApi } from '@renderer/api/blossom'
import { isNotBlank } from '@renderer/assets/utils/obj'
@ -272,14 +272,10 @@ const fillStorePath = (id: string, path: string = ''): void => {
const saveLoading = ref<boolean>(false)
const saveDoc = () => {
saveLoading.value = true
if (!checkLevel(docForm.value.pid, docTreeData!.value)) {
saveLoading.value = false
return
}
// then
const handleResp = (_: any) => {
Notify.success(curDocDialogType === 'upd' ? `修改《${docForm.value.name}》成功` : `新增《${docForm.value.name}》成功`)
emits('saved')
emits('saved', curDocDialogType, docForm.value)
}
const handleFinally = () => setTimeout(() => (saveLoading.value = false), 300)
if (curDocDialogType == 'add')

View File

@ -1,93 +1,92 @@
<template>
<!-- 文件夹操作 -->
<div class="doc-workbench">
<Workbench @refresh-doc-tree="getDocTree" @show-sort="handleShowSort"></Workbench>
<Workbench></Workbench>
</div>
<div class="doc-tree-operator">
<el-tooltip effect="light" popper-class="is-small" placement="top" :offset="4" :hide-after="0" content="显示排序">
<div class="iconbl bl-a-leftdirection-line" @click="handleShowSort"></div>
</el-tooltip>
<el-tooltip effect="light" popper-class="is-small" placement="top" :offset="4" :hide-after="0" content="根目录下新建图片文件夹">
<div class="iconbl bl-a-folderon-line" @click="addFolderToRoot()"></div>
</el-tooltip>
<el-tooltip effect="light" popper-class="is-small" placement="top" :offset="4" :hide-after="0" :show-after="1000" content="搜索">
<div class="iconbl bl-search-line" @click="showTreeFilter()"></div>
</el-tooltip>
<el-tooltip effect="light" popper-class="is-small" placement="top" :offset="4" :hide-after="0" :show-after="1000" content="刷新">
<div class="iconbl bl-refresh-line" @click="refreshDocTree()"></div>
</el-tooltip>
<el-tooltip effect="light" popper-class="is-small" placement="top" :offset="4" :hide-after="0" :show-after="1000" content="折叠所有文件夹">
<div class="iconbl bl-collapse" @click="collapseAll"></div>
</el-tooltip>
<div class="doc-tree-search" ref="DocTreeSearch" v-show="isShowTreeFilter">
<el-input v-model="treeFilterText" style="width: 180px" ref="DocTreeSearchInput">
<template #append>
<div ref="DocTreeSearchMove" style="cursor: move; border-right: 1px solid var(--el-border-color)">
<el-icon><Rank /></el-icon>
</div>
<div style="cursor: pointer" @click="showTreeFilter()">
<el-icon size="14"><Close /></el-icon>
</div>
</template>
</el-input>
</div>
</div>
<div
ref="DocTreeContainer"
class="doc-trees-container"
v-loading="docTreeLoading"
element-loading-text="正在读取文档..."
:style="{ fontSize: configStore.viewStyle.treeDocsFontSize }">
<!-- 文件夹 -->
<el-menu v-if="!isEmpty(docTreeData)" ref="DocTreeRef" class="doc-trees" :unique-opened="configStore.viewStyle.isMenuUniqueOpened">
<!-- ================================================ L1 ================================================ -->
<div v-for="L1 in docTreeData" :key="L1.i">
<div v-if="L1.ty == 11" class="menu-divider" />
<!-- L1无下级 -->
<el-menu-item v-else-if="isEmpty(L1.children)" :index="L1.i">
<template #title>
<div class="menu-item-wrapper" @click="clickCurDoc(L1)" @click.right="handleClickRight(L1, $event)">
<PictureTitle :trees="L1" :level="1" />
</div>
</template>
</el-menu-item>
<!-- L1有下级 -->
<el-sub-menu v-else :expand-open-icon="ArrowDownBold" :expand-close-icon="ArrowRightBold" :index="L1.i">
<template #title>
<div class="menu-item-wrapper" @click="clickCurDoc(L1)" @click.right="handleClickRight(L1, $event)">
<PictureTitle :trees="L1" :level="1" />
</div>
</template>
<!-- ================================================ L2 ================================================ -->
<div v-for="L2 in L1.children" :key="L2.i">
<!-- L2无下级 -->
<el-menu-item v-if="isEmpty(L2.children)" :index="L2.i">
<template #title>
<div class="menu-item-wrapper" @click="clickCurDoc(L2)" @click.right="handleClickRight(L2, $event)">
<PictureTitle :trees="L2" :level="2" />
</div>
</template>
</el-menu-item>
<!-- L2有下级 -->
<el-sub-menu v-else :expand-open-icon="ArrowDownBold" :expand-close-icon="ArrowRightBold" :index="L2.i">
<template #title>
<div class="menu-item-wrapper" @click="clickCurDoc(L2)" @click.right="handleClickRight(L2, $event)">
<PictureTitle :trees="L2" :level="2" />
</div>
</template>
<!-- ================================================ L3 ================================================ -->
<div v-for="L3 in L2.children" :key="L3.i">
<!-- L3无下级 -->
<el-menu-item v-if="isEmpty(L3.children)" :index="L3.i">
<template #title>
<div class="menu-item-wrapper" @click="clickCurDoc(L3)" @click.right="handleClickRight(L3, $event)">
<PictureTitle :trees="L3" :level="3" />
</div>
</template>
</el-menu-item>
<!-- L3有下级 -->
<el-sub-menu v-else :expand-open-icon="ArrowDownBold" :expand-close-icon="ArrowRightBold" :index="L3.i">
<template #title>
<div class="menu-item-wrapper" @click="clickCurDoc(L3)" @click.right="handleClickRight(L3, $event)">
<PictureTitle :trees="L3" :level="3" />
</div>
</template>
<!-- ================================================ L4 ================================================ -->
<div v-for="L4 in L3.children" :key="L4.i">
<!-- L4 不允许有下级, 只允许4级 -->
<el-menu-item v-if="isEmpty(L4.children)" :index="L4.i">
<template #title>
<div class="menu-item-wrapper" @click="clickCurDoc(L4)" style="width: 100%" @click.right="handleClickRight(L4, $event)">
<PictureTitle :trees="L4" :level="4" />
</div>
</template>
</el-menu-item>
</div>
</el-sub-menu>
:style="{ fontSize: viewStyle.treeDocsFontSize }">
<el-tree
v-if="docTreeData.length > 0"
ref="DocTreeRef"
class="doc-tree"
:data="docTreeData"
:allow-drag="handleAllowDrag"
:allow-drop="handleAllowDrop"
:highlight-current="true"
:indent="14"
:icon="ArrowRightBold"
:accordion="false"
:default-expanded-keys="Array.from(docTreeCurrentExpandId)"
:filter-node-method="filterNode"
:draggable="isBlank(treeFilterText)"
node-key="i"
@nodeClick="clickCurDoc"
@nodeExpand="handleNodeExpand"
@nodeCollapse="handleNodeCollapse"
@nodeDrop="handleDrop">
<template #default="{ node, data }">
<div v-if="data.ty === 11" class="menu-divider"></div>
<div v-else class="menu-item-wrapper" @click.right="handleClickRightMenu($event, data)">
<div class="doc-title">
<bl-tag v-if="isShowSort && data.ty === 2" class="sort" :bgColor="getColor(node)">
{{ data.s }}
</bl-tag>
<div class="doc-name">
<img class="menu-icon-img" v-if="isShowImg(data, viewStyle)" :src="data.icon" />
<svg v-else-if="isShowSvg(data, viewStyle)" class="icon menu-icon" aria-hidden="true">
<use :xlink:href="'#' + data.icon"></use>
</svg>
<el-input
v-if="data?.updn"
v-model="data.n"
:id="'article-doc-name-' + data.i"
@blur="blurArticleNameInput(data)"
@keyup.enter="blurArticleNameInput(data)"
style="width: 95%"></el-input>
<div v-else class="name-wrapper" :style="{ maxWidth: isNotBlank(data.icon) ? 'calc(100% - 25px)' : '100%' }">
{{ data.n }}
</div>
</el-sub-menu>
<bl-tag v-for="tag in tags(data, viewStyle)" style="margin-top: 5px" :bg-color="tag.bgColor" :icon="tag.icon">
{{ tag.content }}
</bl-tag>
</div>
</div>
</el-sub-menu>
</div>
</el-menu>
</div>
</template>
</el-tree>
</div>
<!-- 右键菜单, 添加到 body -->
@ -96,14 +95,14 @@
<div class="doc-name">{{ curDoc.n }}</div>
<div class="menu-content">
<div :class="['menu-item', Number(curDoc.i) <= 0 ? 'disabled' : '']" @click="rename"><span class="iconbl bl-pen"></span>重命名</div>
<div :class="['menu-item', Number(curDoc.i) <= 0 || curDoc.ty != 2 ? 'disabled' : '']" @click="handleShowDocInfoDialog('upd')">
<div :class="['menu-item', Number(curDoc.i) <= 0 ? 'disabled' : '']" @click="handleShowDocInfoDialog('upd')">
<span class="iconbl bl-a-fileedit-line"></span>编辑详情
</div>
<div :class="['menu-item', Number(curDoc.i) <= 0 || curDoc.ty != 2 ? 'disabled' : '']" @click="addFolder">
<div :class="['menu-item', Number(curDoc.i) <= 0 ? 'disabled' : '']" @click="addFolderToDoc()">
<span class="iconbl bl-a-fileadd-line"></span>新增文件夹
</div>
<div class="menu-item-divider"></div>
<div :class="['menu-item', Number(curDoc.i) <= 0 || curDoc.ty != 2 ? 'disabled' : '']" @click="delDoc()">
<div :class="['menu-item', Number(curDoc.i) <= 0 ? 'disabled' : '']" @click="delDoc()">
<span class="iconbl bl-a-fileprohibit-line"></span>删除文件夹
</div>
</div>
@ -122,116 +121,355 @@
draggable>
<PictureInfo ref="PictureInfoRef" @saved="savedCallback"></PictureInfo>
</el-dialog>
<div style="position: absolute; top: 5px; left: 5px; z-index: 100; font-size: 12px; color: black; font-weight: 700; text-align: left; width: 700px">
<div>当前选中{{ docTreeCurrentId }}</div>
<div>所有展开{{ Array.from(docTreeCurrentExpandId) + '' }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, provide, nextTick, watch } from 'vue'
import { useConfigStore } from '@renderer/stores/config'
import { ref, provide, nextTick } from 'vue'
import { ArrowDownBold, ArrowRightBold } from '@element-plus/icons-vue'
import Workbench from './PictureTreeWorkbench.vue'
import { docTreeApi, folderAddApi, folderDelApi } from '@renderer/api/blossom'
import { checkLevel, provideKeyDocTree } from '@renderer/views/doc/doc'
import { isEmpty } from 'lodash'
import PictureTitle from './PictureTreeTitle.vue'
import PictureInfo from '@renderer/views/picture/PictureInfo.vue'
import Notify from '@renderer/scripts/notify'
import { ElMessageBox, MenuInstance } from 'element-plus'
import { useUserStore } from '@renderer/stores/user'
// element plus
import { ElMessageBox, TreeNode } from 'element-plus'
import { NodeDropType } from 'element-plus/es/components/tree/src/tree.type'
import { DragEvents } from 'element-plus/es/components/tree/src/model/useDragNode'
import { ArrowRightBold, Rank, Close } from '@element-plus/icons-vue'
import Node from 'element-plus/es/components/tree/src/model/node'
// ts
import { docTreeApi, docUpdSortApi, folderAddApi, folderDelApi, folderUpdNameApi } from '@renderer/api/blossom'
import { provideKeyDocTree } from '@renderer/views/doc/doc'
import { isShowImg, isShowSvg, tags } from '@renderer/views/doc/doc-tree-detail'
import { getColor, handleTreeDrop } from '@renderer/views/doc/doc-tree'
import { useDraggable } from '@renderer/scripts/draggable'
import { useLifecycle } from '@renderer/scripts/lifecycle'
// util
import { isEmpty } from 'lodash'
import { isBlank, isNotBlank } from '@renderer/assets/utils/obj'
// components
import Notify from '@renderer/scripts/notify'
import Workbench from './PictureTreeWorkbench.vue'
import PictureInfo from '@renderer/views/picture/PictureInfo.vue'
const configStore = useConfigStore()
const user = useUserStore()
const { viewStyle } = useConfigStore()
useLifecycle(
() => getDocTree(),
() => getDocTree()
)
watch(
() => user.currentUserId,
(_newVal, _oldVal) => getDocTree()
)
//#region ----------------------------------------< >--------------------------------------
let editorLoadingTimeout: NodeJS.Timeout
const DocTreeRef = ref<MenuInstance>()
const DocTreeRef = ref()
const docTreeLoading = ref(true) //
const showSort = ref(false) //
const isShowSort = ref(false) //
const docTreeData = ref<DocTree[]>([]) //
provide(provideKeyDocTree, docTreeData)
/**
* 获取文档树状列表
* 1. 初始化是全否调用
* 2. workbench 中点击按钮调用, 每个按钮是单选的
* 刷新文档, 并在渲染结束后选中最后一次选中项
*/
const getDocTree = () => {
editorLoadingTimeout = setTimeout(() => (docTreeLoading.value = true), 100)
docTreeApi({ onlyPicture: true })
.then((resp) => {
const docTree: DocTree[] = resp.data
//
let lastPicIndex: number = docTree.length - 1
//
for (let i = 0; i < docTree.length; i++) {
let doc = docTree[i]
if (doc.ty === 1) {
lastPicIndex = i
break
}
const refreshDocTree = () => {
getDocTree(() => {
nextTick(() => {
if (!isEmpty(docTreeData.value) && isNotBlank(docTreeCurrentId.value)) {
DocTreeRef.value.setCurrentKey(docTreeCurrentId.value)
}
//
docTree.splice(Math.max(lastPicIndex, 1), 0, {
i: (Number(docTree[0].i) - 100000).toString(),
p: '0',
n: '',
o: 0,
t: [],
s: 0,
icon: '',
ty: 11,
star: 0
})
docTreeData.value = docTree
concatSort(docTreeData.value)
})
.finally(() => {
if (editorLoadingTimeout) {
clearTimeout(editorLoadingTimeout)
}
docTreeLoading.value = false
})
})
}
/**
* 在名称中显式排序
* @param trees
* 获取文档树状列表
*
* @param callback 获取文档后的自定义回调
*/
const concatSort = (trees: DocTree[]) => {
for (let i = 0; i < trees.length; i++) {
if (!isEmpty(trees[i].children)) {
concatSort(trees[i].children as DocTree[])
}
trees[i].showSort = showSort.value
}
const getDocTree = (callback?: () => void) => {
startLoading()
docTreeApi({ onlyPicture: true })
.then((resp) => {
addTreeDivider(resp.data)
endLoading()
if (callback) callback()
})
.finally(() => endLoading())
}
/**
* 点击菜单
*
* @param tree 点击的菜单节点数据
* @param node 树状菜单 node
* @param treeNode TreeNode
* @param event 点击事件
*/
const clickCurDoc = (tree: DocTree, node: Node, treeNode: TreeNode, event: MouseEvent) => {
closeTreeDocsMenuShow(event)
setCurrentKey(tree, node, treeNode, event)
emits('clickDoc', tree)
}
/**
* 是否显示排序
*/
const handleShowSort = () => {
showSort.value = !showSort.value
concatSort(docTreeData.value)
const handleShowSort = () => (isShowSort.value = !isShowSort.value)
/** 开始加载 */
const startLoading = () => {
if (!editorLoadingTimeout) {
editorLoadingTimeout = setTimeout(() => (docTreeLoading.value = true), 100)
}
}
/** 结束加载 */
const endLoading = () => {
if (editorLoadingTimeout) {
clearTimeout(editorLoadingTimeout)
}
docTreeLoading.value = false
}
/**
* 为树状列表插入图片文件夹和文章文件夹的分割线
*/
const addTreeDivider = (data: any) => {
const docTree: DocTree[] = data
//
let lastPicIndex: number = docTree.length - 1
//
for (let i = 0; i < docTree.length; i++) {
let doc = docTree[i]
if (doc.ty === 1) {
lastPicIndex = i
break
}
}
//
docTree.splice(Math.max(lastPicIndex, 1), 0, {
i: (Number(docTree[0].i) - 100000).toString(),
p: '0',
n: '',
o: 0,
t: [],
s: 0,
icon: '',
ty: 11,
star: 0
})
docTreeData.value = docTree
}
//#endregion
//#region ----------------------------------------< >--------------------------------------
// ,
const docTreeCurrentId = ref('')
//
const docTreeCurrentExpandId = ref<Set<string>>(new Set())
//
const treeFilterText = ref('')
const isShowTreeFilter = ref(false)
//
const DocTreeContainer = ref()
const DocTreeSearch = ref()
const DocTreeSearchMove = ref()
const DocTreeSearchInput = ref()
// ,
let notAllowDragKey: string = ''
useDraggable(DocTreeSearch, DocTreeSearchMove, DocTreeContainer)
watch(treeFilterText, (val) => DocTreeRef.value!.filter(val))
/**
* 设置选中项, 并展开所有上级
*
* @param tree 当前选中的文档
*/
const setCurrentKey = (tree: { i: string; p: string; ty: DocType }, node?: Node, _treeNode?: any, _event?: MouseEvent) => {
if (tree.ty === 1 || tree.ty === 2) {
docTreeCurrentId.value = tree.i
if (node && node.expanded) {
docTreeCurrentExpandId.value.add(tree.i)
}
} else if (tree.ty === 3) {
docTreeCurrentId.value = tree.i
docTreeCurrentExpandId.value.add(tree.p)
}
DocTreeRef.value.setCurrentKey(tree.i)
}
/**
* 显示过滤框
*/
const showTreeFilter = () => {
isShowTreeFilter.value = !isShowTreeFilter.value
if (isShowTreeFilter.value) {
DocTreeSearchInput.value.focus()
}
}
/**
* 过滤节点名称和标签
* @param value 搜索内容
* @param data 列表
* @return 返回节点是否保留
*/
const filterNode = (value: string, data: DocTree): boolean => {
if (!value) return true
return data.n.includes(value) || data.t.toString().includes(value)
}
/**
* 判断是否允许被拖拽
* 1. 文章文件夹不允许拖拽
* 2. 正在重命名的节点不允许被拖拽
*
* @param node 拖动的节点
* @return boolean 节点是否允许被拖动
*/
const handleAllowDrag = (node: Node): boolean => {
return node.data.ty === 2 && notAllowDragKey !== node.data.i
}
/**
* 判断是否允许被节点放置
*
* @param _draggingNode 拖动的节点
* @param dropNode 被防止的节点
* @param type 放置的类型
* @return boolean 是否允许被放置
*/
const handleAllowDrop = (_draggingNode: Node, dropNode: Node, _type: NodeDropType): boolean => {
if (dropNode.data.ty !== 2) {
return false
}
if (dropNode.data.i < 0) {
return false
}
return true
}
/**
* 折叠全部, 清空当前选中状态, 并刷新列表
*/
const collapseAll = () => {
docTreeCurrentExpandId.value.clear()
getDocTree()
}
/**
* 折叠所有无子菜单的文件夹
*/
const collapseNoChild = () => {
nextTick(() => {
for (let i = 0; i < docTreeData.value.length; i++) {
const doc = docTreeData.value[i]
collapseChild(doc)
}
})
}
/**
* 递归折叠所有子文件夹
*
* @param doc
*/
const collapseChild = (doc: DocTree) => {
if (doc.ty === 1 || doc.ty === 2) {
if (isEmpty(doc.children)) {
docTreeCurrentExpandId.value.delete(doc.i)
} else {
for (let i = 0; i < doc.children!.length; i++) {
const cdoc = doc.children![i]
collapseChild(cdoc)
}
}
}
}
/**
* 处理节点展开
*/
const handleNodeExpand = (tree: DocTree, _node: Node) => {
docTreeCurrentExpandId.value.add(tree.i)
}
/**
* 处理节点缩起, 同时清除所有子节点的展开状态
*/
const handleNodeCollapse = (tree: DocTree, node: Node) => {
docTreeCurrentExpandId.value.delete(tree.i)
collapseChilds(node)
}
/**
* 递归缩起所有子节点
*/
const collapseChilds = (node: Node) => {
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i]
if (child.isLeaf) {
} else {
child.expanded = false
docTreeCurrentExpandId.value.delete(child.data.i)
collapseChilds(child)
}
}
}
/**
* 如果父节点没有子节点时, 关闭父节点的展开状态
* @param pid 父ID
*/
const closeParentIfNoChild = (pid: string) => {
let node: Node = DocTreeRef.value.getNode(pid)
if (node && isEmpty(node.childNodes)) {
docTreeCurrentExpandId.value.delete(pid)
}
}
/**
* 拖拽后处理各个节点排序
*/
const handleDrop = (drag: Node, enter: Node, dropType: NodeDropType, _event: DragEvents) => {
handleTreeDrop(drag, enter, dropType, _event, DocTreeRef, docTreeData, (needUpd) => {
docUpdSortApi({ docs: needUpd, folderType: 2, onlyPicture: true })
.then((resp) => {
addTreeDivider(resp.data)
collapseNoChild()
})
.catch(() => getDocTree())
})
}
//#endregion
//#region ----------------------------------------< >--------------------------------------
const curDoc = ref<DocTree>({ i: '0', p: '0', n: '选择菜单', o: 0, t: [], s: 0, icon: '', ty: 1, star: 0 })
const rMenu = ref<RightMenu>({ show: false, clientX: 0, clientY: 0 })
const rMenuHeight = 151 // ,
/**
* 显示有检查菜单
* 显示右键菜单
* 文章文件夹不显示右键菜单, 文章文件夹的管理一律在文章编辑功能中
*
* @param doc 文档
* @param event 事件
*/
const handleClickRight = (doc: DocTree, event: MouseEvent) => {
const handleClickRightMenu = (event: MouseEvent, doc: DocTree) => {
event.preventDefault()
if (!doc) {
return
}
docTreeCurrentExpandId.value.add(doc.p)
if (!doc) return
if (doc.ty !== 2) return
curDoc.value = doc
rMenu.value = { show: false, clientX: 0, clientY: 0 }
let y = event.clientY
@ -244,20 +482,25 @@ const handleClickRight = (doc: DocTree, event: MouseEvent) => {
}, 100)
}
const closeTreeDocsMenuShow = () => {
removeListenerTreeDocsRightMenu()
/**
* 关闭右键菜单, 有子菜单时, 点击菜单不会关闭
*
* @param event 点击事件
*/
const closeTreeDocsMenuShow = (event?: MouseEvent) => {
if (event && event?.target) {
let isPrevent = (event.target as HTMLElement).getAttribute('data-bl-prevet')
if (isPrevent === 'true') {
event.preventDefault()
return
}
}
rMenu.value.show = false
}
const removeListenerTreeDocsRightMenu = () => {
document.body.removeEventListener('click', closeTreeDocsMenuShow)
}
//#endregion
//#region
/**
* 删除文档
* 删除文档, 删除后将文档从树状节点中删除
*/
const delDoc = () => {
ElMessageBox.confirm(`是否确定删除文件夹: <span style="color:#C02B2B;text-decoration: underline;">${curDoc.value.n}</span>?删除后将不可恢复!`, {
@ -269,28 +512,53 @@ const delDoc = () => {
}).then(() => {
folderDelApi({ id: curDoc.value.i }).then((_resp) => {
Notify.success(`删除文件夹成功`)
getDocTree()
DocTreeRef.value.remove(curDoc.value.i)
closeParentIfNoChild(curDoc.value.p)
})
})
}
/** 重命名文章 */
/**
* 重命名文章, 重命名时该节点无法拖拽
*/
const rename = () => {
curDoc.value.updn = true
notAllowDragKey = curDoc.value.i
nextTick(() => {
let ele = document.getElementById('article-doc-name-' + curDoc.value.i)
if (ele) ele.focus()
})
}
/** 在末尾新增文件夹 */
const addFolder = () => {
if (!checkLevel(curDoc.value.i, docTreeData.value)) {
return
}
/**
* 重命名文章失去焦点
*/
const blurArticleNameInput = (doc: DocTree) => {
folderUpdNameApi({ id: doc.i, name: doc.n }).then((_resp) => {
doc.updn = false
notAllowDragKey = ''
})
}
/**
* 在文件夹下新增文件夹
*/
const addFolderToDoc = () => addFolder(curDoc.value.i)
/**
* 在根目录添加文件夹
*/
const addFolderToRoot = () => addFolder('0')
/**
* 在指定 pid 的末尾新增文件夹
*
* @param pid 父ID
*/
const addFolder = (pid: string) => {
//
folderAddApi({ pid: curDoc.value.i, name: '新文件夹', storePath: '/', type: 2, icon: 'wl-folder', sort: 0, addToLast: true }).then((resp) => {
let doc: DocTree = {
folderAddApi({ pid: pid, name: '新文件夹', storePath: '/', type: 2, icon: 'wl-folder', sort: 0, addToLast: true }).then((resp) => {
let newFolder: DocTree = {
i: resp.data.id,
p: resp.data.pid,
n: resp.data.name,
@ -303,21 +571,34 @@ const addFolder = () => {
star: 0,
showSort: curDoc.value.showSort
}
if (isEmpty(curDoc.value.children)) {
curDoc.value.children = [doc]
} else {
curDoc.value.children!.push(doc)
}
nextTick(() => {
DocTreeRef.value!.open(curDoc.value.i.toString())
nextTick(() => {
let ele = document.getElementById('article-doc-name-' + doc.i)
if (ele) ele.focus()
})
})
addDocToTail(newFolder)
})
}
/**
* 将文档添加至末尾并选中
*/
const addDocToTail = (doc: DocTree) => {
if (doc.p !== '0') {
//
DocTreeRef.value.append(doc, DocTreeRef.value.getNode(doc.p))
docTreeCurrentExpandId.value.add(doc.p)
} else {
const picRootFolder: DocTree[] = docTreeData.value.filter((n) => n.ty === 2)
const lastFolder = picRootFolder[picRootFolder.length - 1]
console.log(lastFolder)
DocTreeRef.value.insertAfter(doc, lastFolder.i)
}
nextTick(() => {
let ele = document.getElementById('article-doc-name-' + doc.i) as HTMLInputElement
setTimeout(() => {
if (ele) ele.select()
}, 100)
})
}
//#endregion
//#region ----------------------------------------< >--------------------------------------
const PictureInfoRef = ref()
const isShowDocInfoDialog = ref<boolean>(false)
@ -332,39 +613,41 @@ const handleShowDocInfoDialog = (dialogType: DocDialogType, pid?: number) => {
Notify.info('当前文档为系统默认文档, 无法操作', '操作无效')
return
}
if (dialogType === 'upd' && (curDoc.value == undefined || curDoc.value?.i == undefined)) {
Notify.info('请先选则要修改的文件夹或文档')
return
}
isShowDocInfoDialog.value = true
if (dialogType === 'add') {
nextTick(() => {
PictureInfoRef.value.reload(dialogType, undefined, pid)
})
}
if (dialogType === 'upd') {
nextTick(() => {
PictureInfoRef.value.reload(dialogType, curDoc.value.i)
})
}
if (dialogType === 'add') nextTick(() => PictureInfoRef.value.reload(dialogType, undefined, pid))
if (dialogType === 'upd') nextTick(() => PictureInfoRef.value.reload(dialogType, curDoc.value.i))
}
const savedCallback = () => {
/**
* 保存后回调
*
* @param dialogType 保存类型
* @param doc 文档
*/
const savedCallback = (_dialogType: DocDialogType, doc: DocInfo) => {
isShowDocInfoDialog.value = false
getDocTree()
const oldDoc: DocTree = DocTreeRef.value.getNode(doc.id).data
oldDoc.n = doc.name
oldDoc.t = doc.tags
oldDoc.icon = doc.icon!
if (oldDoc.s !== doc.sort || oldDoc.p !== doc.pid) {
getDocTree(() => {
collapseNoChild()
})
}
}
//#endregion
const clickCurDoc = (tree: DocTree) => {
emits('clickDoc', tree)
}
//#endregion
const emits = defineEmits(['clickDoc'])
</script>
<style scoped lang="scss">
@import '../doc/tree-docs.scss';
@import '../doc/tree-docs-right-menu.scss';
@import '../doc/doc-tree.scss';
@import '../doc/doc-tree-detail.scss';
@import '../doc/doc-tree-right-menu.scss';
</style>

View File

@ -1,149 +0,0 @@
<template>
<div class="doc-title">
<bl-tag class="sort" v-show="props.trees.showSort" :bgColor="levelColor" style="padding: 0 2px">
{{ props.trees.s }}
</bl-tag>
<span class="doc-name">
<img
class="menu-icon-img"
v-if="
viewStyle.isShowArticleIcon &&
isNotBlank(props.trees.icon) &&
(props.trees.icon.startsWith('http') || props.trees.icon.startsWith('https')) &&
!props.trees.updn
"
:src="props.trees.icon" />
<svg v-else-if="viewStyle.isShowArticleIcon && isNotBlank(props.trees.icon) && !props.trees.updn" class="icon menu-icon" aria-hidden="true">
<use :xlink:href="'#' + props.trees.icon"></use>
</svg>
<el-input
v-if="$props.trees?.updn"
v-model="$props.trees.n"
:id="'article-doc-name-' + props.trees.i"
@blur="blurArticleNameInput"
@keyup.enter="blurArticleNameInput"
style="width: 95%"></el-input>
<div v-else class="name-wrapper" :style="nameWrapperStyle">
{{ props.trees.n }}
</div>
<bl-tag v-for="tag in tags" :bg-color="tag.bgColor" style="margin-top: 5px" :icon="tag.icon">
{{ tag.content }}
</bl-tag>
</span>
<div v-if="level >= 2" class="folder-level-line" style="left: -20px"></div>
<div v-if="level >= 3" class="folder-level-line" style="left: -30px"></div>
<div v-if="level >= 4" class="folder-level-line" style="left: -40px"></div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PropType } from 'vue'
import { isNotBlank } from '@renderer/assets/utils/obj'
import { computedDocTitleColor } from '@renderer/views/doc/doc'
import { folderUpdNameApi } from '@renderer/api/blossom'
import { useConfigStore } from '@renderer/stores/config'
const { viewStyle } = useConfigStore()
//#region ----------------------------------------< >--------------------------------------
const props = defineProps({
trees: { type: Object as PropType<DocTree>, default: {} },
level: { type: Number, required: true }
})
const levelColor = computed(() => {
return computedDocTitleColor(props.level)
})
const nameWrapperStyle = computed(() => {
return {
maxWidth: isNotBlank(props.trees.icon) ? 'calc(100% - 25px)' : '100%'
}
})
/**
* 计算标签, 并返回便签对象集合
*/
const tags = computed(() => {
let icons: any = []
props.trees.t?.forEach((tag) => {
if (tag === 'subject') {
icons.unshift({ content: '专题', bgColor: 'var(--bl-tag-color-subject)', icon: 'bl-a-lowerrightpage-line' })
} else if (tag.toLocaleLowerCase() === 'toc') {
if (!viewStyle.isShowArticleTocTag) {
return
}
icons.push({ content: 'TOC', bgColor: 'var(--bl-tag-color-toc)' })
} else if (viewStyle.isShowArticleCustomTag) {
icons.push({ content: tag })
}
})
if (props.trees.o === 1 && props.trees.ty != 3) {
if (viewStyle.isShowFolderOpenTag) {
icons.unshift({ bgColor: 'var(--bl-tag-color-open)', icon: 'bl-cloud-line' })
}
}
return icons
})
/**
* 重命名文章
*/
const blurArticleNameInput = () => {
folderUpdNameApi({ id: props.trees.i, name: props.trees.n }).then((_resp) => {
props.trees.updn = false
})
}
</script>
<style scoped lang="scss">
$icon-size: 17px;
.doc-title {
@include flex(row, flex-start, flex-start);
width: 100%;
padding-bottom: 1px;
position: relative;
.doc-name {
@include flex(row, flex-start, flex-start);
@include themeBrightness(100%, 90%);
@include ellipsis();
font-size: inherit;
align-content: flex-start;
flex-wrap: wrap;
width: 100%;
.menu-icon,
.menu-icon-img {
@include box($icon-size, $icon-size, $icon-size, $icon-size, $icon-size, $icon-size);
margin-top: 5px;
margin-right: 8px;
}
.menu-icon-img {
border-radius: 2px;
}
.name-wrapper {
@include ellipsis();
}
}
.sort {
position: absolute;
right: 0;
top: 2px;
}
.folder-level-line {
@include box(1px, 100%);
background-color: var(--el-border-color);
position: absolute;
opacity: 0;
transition: opacity 0.5s;
}
}
</style>

View File

@ -1,82 +1,17 @@
<template>
<div class="doc-workbench-root">
<bl-col class="workbench-name" just="flex-start" align="flex-end" height="46px" v-show="curFolder !== undefined">
<span>{{ curFolder?.name }}</span>
<span style="font-size: 9px; padding-right: 5px">{{ curFolder?.id }}</span>
</bl-col>
<!-- -->
<bl-row just="flex-end" align="flex-end">
<el-tooltip content="显示排序" effect="light" placement="top" :show-after="1000" :hide-after="0" :auto-close="2000">
<div class="iconbl bl-a-leftdirection-line" @click="emits('show-sort')"></div>
<template #content>
显示排序<br />
<bl-row>
<bl-tag :bgColor="SortLevelColor.ONE">一级</bl-tag>
<bl-tag :bgColor="SortLevelColor.TWO">二级</bl-tag>
</bl-row>
<bl-row>
<bl-tag :bgColor="SortLevelColor.THREE">三级</bl-tag>
<bl-tag :bgColor="SortLevelColor.FOUR">四级</bl-tag>
</bl-row>
</template>
</el-tooltip>
<el-tooltip content="刷新列表" effect="light" placement="top" :show-after="1000" :hide-after="0" :auto-close="2000">
<div class="iconbl bl-a-cloudrefresh-line" @click="refreshDocTree()"></div>
</el-tooltip>
<el-tooltip content="新增图片文件夹" effect="light" placement="top" :show-after="1000" :hide-after="0" :auto-close="2000">
<div class="iconbl bl-a-fileadd-line" @click="handleShowAddDocInfoDialog()"></div>
</el-tooltip>
</bl-row>
<bl-row just="flex-end" align="flex-end"> </bl-row>
</div>
<el-dialog
v-model="isShowDocInfoDialog"
width="535"
top="100px"
style="margin-left: 320px"
:append-to-body="true"
:destroy-on-close="true"
:close-on-click-modal="false"
draggable>
<PictureInfo ref="PictureInfoRef" @saved="savedCallback"></PictureInfo>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, nextTick, inject } from 'vue'
import { provideKeyDocInfo, SortLevelColor } from '@renderer/views/doc/doc'
import PictureInfo from '@renderer/views/picture/PictureInfo.vue'
//
const curFolder = inject(provideKeyDocInfo)
import { ref, nextTick } from 'vue'
// ---------- ----------
const PictureInfoRef = ref()
// dialog
const isShowDocInfoDialog = ref<boolean>(false)
const handleShowAddDocInfoDialog = () => {
isShowDocInfoDialog.value = true
nextTick(() => {
PictureInfoRef.value.reload('add')
})
}
const refreshDocTree = () => {
emits('refreshDocTree')
}
/**
* 保存后的回调
* 1. [暂无] 刷新菜单列表
* 2. 关闭 dialog 页面
*/
const savedCallback = () => {
isShowDocInfoDialog.value = false
refreshDocTree()
}
//#endregion
const emits = defineEmits(['refreshDocTree', 'show-sort'])
</script>
<style scoped lang="scss">

View File

@ -126,7 +126,7 @@ export const onUploadSeccess: UploadProps['onSuccess'] = (resp, _file?) => {
}
/**
*
* ,
* @param resp
* @returns
*/

View File

@ -0,0 +1,6 @@
@font-face {
font-family: 'Jetbrains Mono';
src: url('@/assets/fonts/JetBrainsMono-Regular.woff2');
font-weight: 400;
font-style: normal;
}

View File

@ -1,3 +1,5 @@
@import '../fonts/config.scss';
@import './element/dialog';
@import './element/drawer';
@import './element/popper';

View File

@ -39,11 +39,10 @@ body {
/* 定义滑块 内阴影+圆角 */
::-webkit-scrollbar-thumb {
border-radius: 4px;
background-color: #c7c7c7;
}
/* 解决滚动条右下角出现白色方块 */
/* 滚动条右下角出现白色方块 */
::-webkit-scrollbar-corner {
background: transparent;
}

View File

@ -1,6 +1,6 @@
:root {
/* 数字越大, 颜色越淡 */
--el-color-primary: #c1992f;
/* --el-color-primary: #c1992f;
--el-color-primary-dark-2: rgba(250, 195, 56, 0.8);
--el-color-primary-light-1: rgba(193, 153, 47, 0.9);
--el-color-primary-light-2: rgba(193, 153, 47, 0.8);
@ -9,7 +9,20 @@
--el-color-primary-light-6: rgba(193, 153, 47, 0.4);
--el-color-primary-light-7: rgba(193, 153, 47, 0.3);
--el-color-primary-light-8: rgba(193, 153, 47, 0.2);
--el-color-primary-light-9: rgba(232, 219, 190, 0.505);
--el-color-primary-light-9: rgba(232, 219, 190, 0.505); */
--el-color-primary: rgb(104, 104, 104);
--el-color-primary-dark: rgb(104, 104, 104);
--el-color-primary-dark-2: rgba(104, 104, 104, 0.8);
--el-color-primary-light-1: rgba(104, 104, 104, 0.9);
--el-color-primary-light-2: rgba(104, 104, 104, 0.8);
--el-color-primary-light-3: rgba(104, 104, 104, 0.7);
--el-color-primary-light-4: rgba(104, 104, 104, 0.6);
--el-color-primary-light-5: rgba(104, 104, 104, 0.5);
--el-color-primary-light-6: rgba(104, 104, 104, 0.4);
--el-color-primary-light-7: rgba(104, 104, 104, 0.3);
--el-color-primary-light-8: rgba(104, 104, 104, 0.2);
--el-color-primary-light-9: rgba(104, 104, 104, 0.1);
/* 文本颜色 */
--el-text-color-primary: #606266;

View File

@ -53,8 +53,8 @@ const props = defineProps({
@include flex(row, center, center);
box-shadow: 2px 2px 3px 0 #999999;
border-radius: 4px;
padding: 0px 4px;
margin: 3px;
padding: 0 4px;
margin: 4px;
height: 15px;
min-height: 15px;
max-height: 15px;

View File

@ -11,7 +11,7 @@
<script setup lang="ts">
import { Plus, Minus } from '@element-plus/icons-vue'
import { increase, decrease, getFontSizeValue } from './article-setting'
import { increase, decrease, getFontSizeValue } from './scripts/article-setting'
</script>
<style scoped lang="scss">

View File

@ -13,83 +13,44 @@
</bl-row>
</div>
<div class="main">
<div class="menu" :style="menuStyle">
<el-menu
v-if="docTreeData != undefined && docTreeData.length > 0"
class="doc-trees"
:default-active="docTreeDefaultActive"
:default-openeds="defaultOpeneds"
:unique-opened="true">
<div v-for="L1 in docTreeData" :key="L1.i" class="menu-level-one">
<el-menu-item v-if="isEmpty(L1.children)" :index="L1.i">
<template #title>
<div class="menu-item-wrapper" @click="clickCurDoc(L1)">
<DocTitle :trees="L1" :level="1" />
</div>
</template>
</el-menu-item>
<el-sub-menu v-else :expand-open-icon="ArrowDownBold" :expand-close-icon="ArrowRightBold" :index="L1.i">
<template #title>
<div class="menu-item-wrapper">
<DocTitle :trees="L1" :level="1" style="font-size: 15px" />
</div>
</template>
<!-- ================================================ L2 ================================================ -->
<div v-for="L2 in L1.children" :key="L2.i">
<el-menu-item v-if="isEmpty(L2.children)" :index="L2.i">
<template #title>
<div class="menu-item-wrapper" @click="clickCurDoc(L2)">
<DocTitle :trees="L2" :level="2" />
</div>
</template>
</el-menu-item>
<el-sub-menu v-else :expand-open-icon="ArrowDownBold" :expand-close-icon="ArrowRightBold" :index="L2.i">
<template #title>
<div class="menu-item-wrapper">
<DocTitle :trees="L2" :level="2" />
</div>
</template>
<!-- ================================================ L3 ================================================ -->
<div v-for="L3 in L2.children" :key="L3.i">
<el-menu-item v-if="isEmpty(L3.children)" :index="L3.i">
<template #title>
<div class="menu-item-wrapper" @click="clickCurDoc(L3)">
<DocTitle :trees="L3" :level="3" />
</div>
</template>
</el-menu-item>
<el-sub-menu v-else :expand-open-icon="ArrowDownBold" :expand-close-icon="ArrowRightBold" :index="L3.i">
<template #title>
<div class="menu-item-wrapper">
<DocTitle :trees="L3" :level="3" />
</div>
</template>
<!-- ================================================ L4 ================================================ -->
<div v-for="L4 in L3.children" :key="L4.i">
<el-menu-item v-if="isEmpty(L4.children)" :index="L4.i">
<template #title>
<div class="menu-item-wrapper" @click="clickCurDoc(L4)">
<DocTitle :trees="L4" :level="4" />
</div>
</template>
</el-menu-item>
</div>
</el-sub-menu>
<div class="doc-tree-container" :style="menuStyle">
<el-tree
ref="DocTreeRef"
class="doc-tree"
:data="docTreeData"
:highlight-current="true"
:indent="14"
:icon="ArrowRightBold"
node-key="i"
@nodeClick="clickCurDoc">
<template #default="{ _node, data }">
<div
class="menu-item-wrapper"
:style="{
marginTop: data.p === '0' ? '5px' : '1px',
marginBottom: data.p === '0' ? '5px' : '1px'
}">
<div :class="[data.t.includes('subject') && userStore.userParams.WEB_BLOG_SUBJECT_TITLE === '1' ? 'subject-title' : 'doc-title']">
<div class="doc-name">
<img class="menu-icon-img" v-if="isShowImg(data)" :src="data.icon" />
<svg v-else-if="isShowSvg(data)" class="icon menu-icon" aria-hidden="true">
<use :xlink:href="'#' + data.icon"></use>
</svg>
<div class="name-wrapper" :style="{ maxWidth: isNotBlank(data.icon) ? 'calc(100% - 25px)' : '100%' }">
{{ data.n }}
</div>
</el-sub-menu>
<bl-tag v-for="tag in tags(data)" style="margin-top: 3px" :bg-color="tag.bgColor" :icon="tag.icon">
{{ tag.content }}
</bl-tag>
</div>
</div>
</el-sub-menu>
</div>
</el-menu>
</div>
</template>
</el-tree>
</div>
<div class="article" ref="PreviewRef">
<div class="doc-content-container" ref="PreviewRef" :style="{ fontSize: getFontSize() }">
<div class="article-name">{{ article.name }}</div>
<div class="bl-preview" :style="{ fontSize: getFontSize() }" v-html="article.html"></div>
</div>
@ -140,15 +101,21 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { ref, onUnmounted, nextTick } from 'vue'
import { ArrowDownBold, ArrowRightBold, Setting } from '@element-plus/icons-vue'
import { articleInfoOpenApi, articleInfoApi, docTreeOpenApi, docTreeApi } from '@/api/blossom'
import { useUserStore } from '@/stores/user'
import { isNull, isEmpty, isNotNull } from '@/assets/utils/obj'
import { useLifecycle } from '@/scripts/lifecycle'
import DocTitle from './DocTitle.vue'
import ArticleSetting from './ArticleSetting.vue'
// element plus
import { ArrowRightBold, Setting } from '@element-plus/icons-vue'
// ts
import 'katex/dist/katex.min.css'
import { getFontSize } from './article-setting'
import { getFontSize } from './scripts/article-setting'
import { parseToc, toScroll, type Toc } from './scripts/doc-toc'
import { isShowImg, isShowSvg, tags } from './scripts/doc-tree-detail'
import { onHtmlEventDispatch } from './scripts/doc-content-event-dispatch'
import { articleInfoOpenApi, articleInfoApi, docTreeOpenApi, docTreeApi } from '@/api/blossom'
// utils
import { isNull, isNotNull, isNotBlank } from '@/assets/utils/obj'
// components
import ArticleSetting from './ArticleSetting.vue'
const userStore = useUserStore()
useLifecycle(
@ -169,14 +136,20 @@ onUnmounted(() => {
window.removeEventListener('resize', onresize)
})
const DocTreeRef = ref()
/**
* 路由中获取ID参数
*/
const getRouteQueryParams = () => {
let articleId = route.query.articleId
getDocTree()
getDocTree(() => {
nextTick(() => {
DocTreeRef.value.setCurrentKey(docTreeCurrentId.value)
})
})
if (isNotNull(articleId)) {
docTreeDefaultActive.value = articleId as string
docTreeCurrentId.value = articleId as string
let treeParam: any = { ty: 3, i: articleId }
clickCurDoc(treeParam)
}
@ -186,7 +159,7 @@ const route = useRoute()
//
const docTreeLoading = ref(true)
//
const docTreeDefaultActive = ref('')
const docTreeCurrentId = ref('')
const docTreeData = ref<DocTree[]>([])
// ,
const article = ref<DocInfo>({
@ -210,7 +183,7 @@ const PreviewRef = ref()
* 1. 初始化是全否调用
* 2. workbench 中点击按钮调用, 每个按钮是单选的
*/
const getDocTree = () => {
const getDocTree = (callback?: () => void) => {
docTreeLoading.value = true
docTreeData.value = []
defaultOpeneds.value = []
@ -220,6 +193,7 @@ const getDocTree = () => {
docTreeData.value.forEach((l1: DocTree) => {
defaultOpeneds.value.push(l1.i.toString())
})
if (callback) callback()
}
if (userStore.isLogin) {
@ -252,8 +226,8 @@ const clickCurDoc = async (tree: DocTree) => {
/**
* 如果点击的是文章, 则查询文章信息和正文, 并在编辑器中显示.
*/
const getCurEditArticle = async (id: number) => {
if (id == -999) {
const getCurEditArticle = async (id: string) => {
if (id === '-999') {
article.value.html = `<div style="color:#C6C6C6;font-weight: 300;width:100%;height:300px;padding:150px 20px;font-size:25px;text-align:center">
该博客所配置的 USER_ID <br/><span style="color:#e3a300; border-bottom: 5px solid #e3a300;border-radius:5px">${window.blconfig.DOMAIN.USER_ID}</span>
</div>`
@ -277,80 +251,10 @@ const getCurEditArticle = async (id: number) => {
* 解析目录
*/
const parseTocAsync = async (ele: HTMLElement) => {
let heads = ele.querySelectorAll('h1, h2, h3, h4, h5, h6')
let tocs: Toc[] = []
for (let i = 0; i < heads.length; i++) {
let head: Element = heads[i]
let level = 1
let content = (head as HTMLElement).innerText
let id = head.id
switch (head.localName) {
case 'h1':
level = 1
break
case 'h2':
level = 2
break
case 'h3':
level = 3
break
case 'h4':
level = 4
break
case 'h5':
level = 5
break
case 'h6':
level = 6
break
}
let toc: Toc = { content: content, clazz: 'toc-' + level, id: id }
tocs.push(toc)
}
tocList.value = tocs
tocList.value = parseToc(ele)
}
const toScroll = (id: string) => {
let elm = document.getElementById(id)
elm?.scrollIntoView(true)
}
/**
* 监听 html 的内联事件
*/
type ArticleHtmlEvent = 'copyPreCode' | 'showArticleReferenceView'
const onHtmlEventDispatch = (_t: any, _ty: any, _event: any, type: ArticleHtmlEvent, data: any) => {
if (type === 'copyPreCode') {
let code = document.getElementById(data)
if (code) {
// navigator.clipboard.writeText(code.innerText)
// navigator clipboard https
if (navigator.clipboard && window.isSecureContext) {
// navigator clipboard
return navigator.clipboard.writeText(code.innerText)
} else {
// text area
let textArea = document.createElement('textarea')
textArea.value = code.innerText
// 使text areaviewport
textArea.style.position = 'absolute'
textArea.style.opacity = '0'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
return new Promise<void>((res, rej) => {
//
document.execCommand('copy') ? res() : rej()
textArea.remove()
})
}
}
}
}
//#region setting
//#region ----------------------------------------< setting >----------------------------------------
const isShowSetting = ref(false)
const showSetting = () => {
@ -444,6 +348,11 @@ const onresize = () => {
</script>
<style scoped lang="scss">
@import './styles/doc-content.scss';
@import './styles/doc-toc.scss';
@import './styles/doc-tree.scss';
@import './styles/doc-tree-detail.scss';
.articles-root {
@include box(100vw, 100%);
@include flex(column, flex-start, center);
@ -476,622 +385,6 @@ const onresize = () => {
@include flex(row, center, center);
padding: 10px;
overflow: hidden;
.menu {
@include box(240px, 100%, 240px, 240px);
border-right: 1px solid var(--el-border-color);
font-weight: 200;
transition: 0.1s;
&:hover {
:deep(.folder-level-line) {
opacity: 1;
}
}
.menu-level-one {
margin-top: 8px;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 8px;
&:first-child {
margin-top: 0px;
}
}
.menu-item-wrapper {
width: 100%;
}
.doc-trees {
@include box(100%, 100%);
font-weight: 200;
padding-right: 0;
border: 0;
overflow-y: scroll;
// padding-right: 6px;
// padding
--el-menu-base-level-padding: 25px;
//
--el-menu-level-padding: 10px;
// ------------------- sub-item
// sub-item
--el-menu-sub-item-height: 25px;
// ------------------- item
// item
--el-menu-item-height: 25px;
// item
--el-menu-item-font-size: 13px;
--el-color-primary-light-9: #ffffff00;
--el-menu-hover-bg-color: #ffffff00;
:deep(.el-menu) {
transition: 0.1s !important;
}
:deep(.el-sub-menu) {
.el-sub-menu__title {
height: auto;
min-height: 25px;
padding-right: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 5px;
.el-sub-menu__icon-arrow {
right: calc(220px - var(--el-menu-level) * 14px);
color: #b3b3b3;
width: 0.8em;
height: 0.8em;
}
}
}
:deep(.el-menu-item) {
--el-menu-hover-bg-color: #ffffff00 !important;
height: auto;
min-height: 25px;
padding-right: 0;
border-radius: 5px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: 0.1s;
}
:deep(.el-menu-item.is-active) {
text-shadow: 0px 4px 5px rgba(107, 104, 104, 1);
background: linear-gradient(90deg, #3d454d00 0%, #c3c3c3 40%, #c3c3c3 60%, #3d454d00 100%);
color: #ffffff;
font-weight: 700;
}
:deep(.el-badge__content) {
top: 7px;
transform: translateY(-50%) translateX(100%) scale(0.8);
}
}
}
.toc-container {
@include box(270px, 100%, 270px, 270px);
border-left: 1px solid #eeeeee;
overflow: auto;
transition: 0.3s;
.viewer-toc {
@include box(100%, 100%);
color: #5e5e5e;
padding: 10px;
z-index: 1000;
transition: 0.3s;
.toc-title {
@include box(100%, 40px);
@include font(30px, 700);
line-height: 40px;
margin-top: 10px;
padding-top: 10px;
border-top: 3px solid #bcbcbc;
}
.toc-subtitle {
width: 100%;
@include flex(row, flex-start, center);
@include font(12px);
color: #ababab;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
white-space: pre;
margin-top: 5px;
}
.toc-content {
@include font(14px);
width: 100%;
overflow-y: auto;
margin-top: 10px;
padding-top: 10px;
.toc-1,
.toc-2,
.toc-3,
.toc-4,
.toc-5,
.toc-6 {
cursor: pointer;
&:hover {
font-weight: bold;
}
}
.toc-1 {
font-size: 1.1em;
margin-top: 5px;
padding-top: 5px;
&:first-child {
margin: 0;
padding: 0;
border: 0;
}
}
.toc-2 {
padding-left: 10px;
}
.toc-3 {
padding-left: 20px;
}
.toc-4 {
padding-left: 30px;
}
.toc-5 {
padding-left: 40px;
}
.toc-6 {
padding-left: 50px;
}
}
}
}
.article {
height: 100%;
width: 1260px;
max-width: 1260px;
overflow-y: overlay;
overflow-x: hidden;
padding: 0 30px;
.bl-preview {
$borderRadius: 4px;
color: #4b4b4b;
font-size: inherit;
line-height: 1.6;
:deep(svg) {
max-width: 100% !important;
}
:deep(.katex > *) {
font-size: 1.2em !important;
// font-family: 'KaTeX_Size1', sans-serif !important;
// font-size: 1.3em !important;
// font-family: 'KaTeX_Math', sans-serif !important;
}
::-webkit-scrollbar {
/* 滚动条里槽的背景色 */
background-color: transparent;
}
/* 定义滑块 内阴影+圆角 */
::-webkit-scrollbar-thumb {
border-radius: 4px;
background-color: #967777;
}
:deep(a) {
color: var(--el-color-primary);
font-weight: bold;
}
:deep(a.inner-link) {
border-bottom: 2px dashed var(--el-color-primary);
box-sizing: border-box;
padding: 0 4px;
text-decoration: none;
}
:deep(img) {
border-radius: $borderRadius;
max-width: 100%;
}
//
:deep(h1) {
padding: 10px 0;
margin-top: 70px;
border-bottom: 2px solid #e5e5e5;
text-align: left;
position: relative;
font-size: 1.8em;
}
:deep(h2) {
font-size: 1.6em;
}
:deep(h3) {
font-size: 1.4em;
}
:deep(h4) {
font-size: 1.3em;
}
:deep(h5) {
font-size: 1.2em;
}
:deep(h6) {
font-size: 1.1em;
}
:deep(h1:first-child) {
margin-top: 20px;
}
:deep(li::marker) {
color: #989898;
}
/* 有序列表 */
:deep(ol) {
padding-left: 2em;
}
/* 无序列表 */
:deep(ul) {
padding-left: 2em;
}
/* checkbox */
:deep(li) {
input {
margin: 0 0 0 -1.4em;
}
&:has(> input)::marker {
content: none;
}
& :has(> p > input)::marker {
content: none;
}
}
:deep(hr) {
border-color: var(--el-color-primary-light-7);
}
//
:deep(table) {
border: 1px solid #939393;
box-sizing: border-box;
padding: 0;
border-spacing: 0;
margin: 10px 0;
max-width: 100%;
// table-layout: fixed;
table-layout: auto;
width: 100%;
thead {
background-color: #2b2b2b;
color: #d4d4d4;
code {
background-color: #000000;
}
tr {
th {
font-size: 16px;
padding: 10px;
border-right: 1px solid #6e6e6e;
}
th:last-child {
border: 0;
}
}
}
tbody {
tr {
td {
padding: 5px;
border-right: 1px solid #939393;
border-bottom: 1px solid #939393;
word-wrap: break-word;
width: auto;
}
td:last-child {
border-right: 0;
}
}
tr:last-child {
td {
border-bottom: 0;
}
}
}
}
:deep(.bl-table-container) {
border: 0;
thead {
display: none;
}
tbody {
td {
border: 0;
}
}
}
//
:deep(blockquote) {
padding: 1px 10px;
margin: 10px 0;
color: #7d7d7d;
background-color: #f0f0f0;
border-left: 3px solid #bebebe;
border-radius: $borderRadius;
blockquote {
border: 1px solid #dedede;
border-left: 3px solid #bebebe;
}
}
:deep(.bl-blockquote-green) {
background-color: #edf8db;
border-left: 3px solid #bed609;
}
:deep(.bl-blockquote-yellow) {
background-color: #faf0d5;
border-left: 3px solid #efc75e;
}
:deep(.bl-blockquote-red) {
background-color: #fbe6e9;
border-left: 3px solid #ff9090;
}
:deep(.bl-blockquote-blue) {
background-color: #dfeefd;
border-left: 3px solid #81bbf8;
}
:deep(.bl-blockquote-purple) {
background-color: #ece4fb;
border-left: 3px solid #ba9bf2;
}
:deep(.bl-blockquote-black) {
background-color: rgba(0, 0, 0, 0.7);
border-left: 3px solid #000000;
}
//
:deep(code) {
background-color: #dedede;
padding: 0px 4px;
border-radius: 3px;
margin: 0 3px;
word-wrap: break-word;
word-break: break-all;
}
//
:deep(pre) {
padding: 0 0 0 30px;
background-color: #2b2b2b;
border-radius: $borderRadius;
box-shadow: 2px 2px 5px rgb(76, 76, 76);
position: relative;
overflow: hidden;
::-webkit-scrollbar-thumb {
border-radius: 4px;
background-color: #5c5c5c;
}
.pre-copy {
position: absolute;
top: 5px;
right: 5px;
text-align: right;
z-index: 10;
color: #5c5c5c;
padding: 1px 8px;
border-radius: 4px;
cursor: pointer;
user-select: none;
}
.pre-copy:hover {
background-color: #1a1a1a;
color: #9d9d9d;
}
.pre-copy:active {
color: #e2e2e2;
}
ol {
margin: 0;
padding-left: 0;
position: absolute;
top: 15px;
left: 3px;
user-select: none;
li {
list-style: none;
.line-num {
width: 30px;
display: inline-block;
text-align: right;
padding-right: 10px;
color: #6a6a6a;
user-select: none;
}
}
}
code {
background-color: inherit;
border-radius: 0;
margin: 0;
height: 100%;
width: 100%;
display: block;
padding: 15px 0 15px 0;
overflow: auto;
}
pre code.hljs {
display: block;
overflow-x: auto;
}
code.hljs {
text-shadow: none;
}
.hljs {
color: #a9b7c6;
background: #2b2b2b;
}
.hljs ::selection,
.hljs::selection {
// background-color: #323232;
// color: #a9b7c6;
}
.hljs-comment {
color: #606366;
}
.hljs-tag {
color: #a4a3a3;
}
.hljs-operator,
.hljs-punctuation,
.hljs-subst {
color: #a9b7c6;
}
.hljs-operator {
opacity: 0.7;
}
.hljs-bullet,
.hljs-deletion,
.hljs-name,
.hljs-selector-tag,
.hljs-template-variable,
.hljs-variable {
color: #4eade5;
}
.hljs-attr {
color: #cc7832;
}
.hljs-link,
.hljs-literal,
.hljs-number,
.hljs-symbol,
.hljs-variable.constant_ {
color: #689757;
}
.hljs-class .hljs-title,
.hljs-title,
.hljs-title.class_ {
color: #e4b568;
}
.hljs-strong {
font-weight: 700;
color: #bbb529;
}
.hljs-addition,
.hljs-code,
.hljs-string,
.hljs-title.class_.inherited__ {
color: #6a8759;
}
.hljs-built_in,
.hljs-doctag,
.hljs-keyword.hljs-atrule,
.hljs-quote,
.hljs-regexp {
color: #629755;
}
.hljs-attribute,
.hljs-function .hljs-title,
.hljs-section,
.hljs-title.function_,
.ruby .hljs-property {
color: #9876aa;
}
.diff .hljs-meta,
.hljs-keyword,
.hljs-template-tag,
.hljs-type {
color: #cc7832;
}
.hljs-emphasis {
color: #cc7832;
font-style: italic;
}
.hljs-meta,
.hljs-meta .hljs-keyword,
.hljs-meta .hljs-string {
color: #b4b428;
}
.hljs-meta .hljs-keyword,
.hljs-meta-keyword {
font-weight: 700;
}
}
}
}
}
// 1100 使
@ -1107,7 +400,7 @@ const onresize = () => {
@include box(100%, calc(100% - 40px));
padding: 0;
.menu {
.doc-tree-container {
height: calc(100% - 20px) !important;
position: absolute;
left: 10px;
@ -1119,7 +412,7 @@ const onresize = () => {
overflow: hidden;
}
.article {
.doc-content-container {
padding: 0 10px;
overflow-x: hidden;

View File

@ -1,175 +0,0 @@
<template>
<div :class="titleClass">
<div class="doc-name">
<img
class="menu-icon-img"
v-if="isNotBlank(props.trees.icon) && (props.trees.icon.startsWith('http') || props.trees.icon.startsWith('https'))"
:src="props.trees.icon" />
<svg v-else-if="isNotBlank(props.trees.icon)" class="icon menu-icon" aria-hidden="true">
<use :xlink:href="'#' + props.trees.icon"></use>
</svg>
<div class="name-wrapper" :style="nameWrapperStyle">
{{ props.trees.n }}
</div>
<bl-tag v-for="tag in tags" style="margin-top: 5px" :bg-color="tag.bgColor" :icon="tag.icon">{{ tag.content }}</bl-tag>
</div>
<div v-if="level === 2" class="folder-level-line" style="left: -26px"></div>
<div v-if="level === 3" class="folder-level-line" style="left: -36px"></div>
<div v-if="level === 3" class="folder-level-line" style="left: -22px"></div>
<div v-if="level === 4" class="folder-level-line" style="left: -46px"></div>
<div v-if="level === 4" class="folder-level-line" style="left: -32px"></div>
<div v-if="level === 4" class="folder-level-line" style="left: -18px"></div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PropType } from 'vue'
import { useUserStore } from '@/stores/user'
import { isNotBlank } from '@/assets/utils/obj'
const userStore = useUserStore()
//#region ----------------------------------------< >--------------------------------------
const props = defineProps({
trees: { type: Object as PropType<DocTree>, default: {} },
level: { type: Number, required: true }
})
const nameWrapperStyle = computed(() => {
return {
maxWidth: isNotBlank(props.trees.icon) ? 'calc(100% - 25px)' : '100%'
}
})
/**
* 计算标签, 并返回便签对象集合
*/
const tags = computed(() => {
let icons: any = []
props.trees.t?.forEach((tag) => {
if (tag === 'subject') {
icons.unshift({ content: '专题', bgColor: '#C0C0C0', icon: 'bl-a-lowerrightpage-line' })
} else if (tag === 'toc') {
icons.push({ content: 'TOC', bgColor: '#c1992f' })
} else {
icons.push({ content: tag })
}
})
return icons
})
const titleClass = computed(() => {
// if (!getThemeSubjecTitle()) {
// return 'doc-title'
// }
if (props.trees.t.includes('subject') && userStore.userParams.WEB_BLOG_SUBJECT_TITLE === '1') {
return 'subject-title'
}
return 'doc-title'
})
//#endregion
</script>
<style scoped lang="scss">
$icon-size: 17px;
.doc-title {
@include flex(row, flex-start, flex-start);
width: 100%;
padding-bottom: 1px;
position: relative;
.doc-name {
@include flex(row, flex-start, flex-start);
@include ellipsis();
font-size: inherit;
align-content: flex-start;
flex-wrap: wrap;
width: 100%;
.menu-icon,
.menu-icon-img {
@include box($icon-size, $icon-size, $icon-size, $icon-size, $icon-size, $icon-size);
margin-top: 5px;
margin-right: 8px;
}
.menu-icon-img {
border-radius: 2px;
}
.name-wrapper {
@include ellipsis();
}
}
.sort {
position: absolute;
padding: 0 2px;
right: 0px;
top: 2px;
z-index: 10;
}
.folder-level-line {
height: 100%;
}
}
.folder-level-line {
width: 1.5px;
background-color: var(--el-border-color);
box-sizing: border-box;
position: absolute;
opacity: 0;
transition: opacity 0.5s;
}
// ,
.subject-title {
@include flex(row, flex-start, flex-start);
background: linear-gradient(135deg, #ffffff, #f0f0f0, #cacaca);
box-shadow: 1px 1px 5px #a2a2a2;
max-width: calc(100% - 15px);
min-width: calc(100% - 15px);
padding: 2px 5px;
margin: 5px 0 10px 0;
border-radius: 7px;
position: relative;
.doc-name {
@include flex(row, flex-start, flex-start);
color: var(--el-color-primary);
align-content: flex-start;
flex-wrap: wrap;
width: 100%;
.menu-icon,
.menu-icon-img {
@include box($icon-size, $icon-size, $icon-size, $icon-size, $icon-size, $icon-size);
margin-top: 5px;
margin-right: 8px;
}
.menu-icon-img {
border-radius: 2px;
}
.name-wrapper {
@include ellipsis();
max-width: calc(100% - 25px);
min-width: calc(100% - 25px);
}
}
.sort {
position: absolute;
right: -15px;
}
.folder-level-line {
height: calc(100% + 25px);
top: -5px;
}
}
</style>

View File

@ -1,6 +1,7 @@
declare interface DocTree {
/** ID */
i: number
i: string
p: string
/** Name */
n: string
/** open: 0:否;1:是; */
@ -53,12 +54,3 @@ declare type DocType = 1 | 2 | 3
declare interface Window {
onHtmlEventDispatch: any
}
/**
*
*/
declare interface Toc {
content: string
clazz: string
id: string
}

View File

@ -0,0 +1,48 @@
/**
* html
*/
export type ArticleHtmlEvent =
| 'copyPreCode' // 代码复制
| 'showArticleReferenceView' // 显示引用文章内容
/**
*
*
* blog (copyPreCode)
*
* @param _t
* @param _ty
* @param _event
* @param type
* @param data
* @returns
*/
export const onHtmlEventDispatch = (_t: any, _ty: any, _event: any, type: ArticleHtmlEvent, data: any) => {
if (type === 'copyPreCode') {
let code = document.getElementById(data)
if (code) {
// navigator clipboard 需要https等安全上下文
if (navigator.clipboard && window.isSecureContext) {
// navigator clipboard 向剪贴板写文本
return navigator.clipboard.writeText(code.innerText)
} else {
// 创建text area
let textArea = document.createElement('textarea')
textArea.value = code.innerText
// 使text area不在viewport同时设置不可见
textArea.style.position = 'absolute'
textArea.style.opacity = '0'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
return new Promise<void>((res, rej) => {
// 执行复制命令并移除文本框
document.execCommand('copy') ? res() : rej()
textArea.remove()
})
}
}
}
}

View File

@ -0,0 +1,57 @@
/**
*
*/
export interface Toc {
content: string
clazz: string
id: string
}
/**
*
*
* @param ele
* @returns
*/
export const parseToc = (ele: HTMLElement): Toc[] => {
let heads = ele.querySelectorAll('h1, h2, h3, h4, h5, h6')
let tocs: Toc[] = []
for (let i = 0; i < heads.length; i++) {
let head: Element = heads[i]
let level = 1
let content = (head as HTMLElement).innerText
let id = head.id
switch (head.localName) {
case 'h1':
level = 1
break
case 'h2':
level = 2
break
case 'h3':
level = 3
break
case 'h4':
level = 4
break
case 'h5':
level = 5
break
case 'h6':
level = 6
break
}
let toc: Toc = { content: content, clazz: 'toc-' + level, id: id }
tocs.push(toc)
}
return tocs
}
/**
*
*
* @param id
*/
export const toScroll = (id: string) => {
let elm = document.getElementById(id)
elm?.scrollIntoView(true)
}

View File

@ -0,0 +1,40 @@
import { isNotBlank } from '@/assets/utils/obj'
/**
*
* @param doc
* @param viewStyle
* @returns
*/
export const isShowImg = (doc: DocTree) => {
return isNotBlank(doc.icon) && (doc.icon.startsWith('http') || doc.icon.startsWith('https'))
}
/**
* svg
* @param doc
* @param viewStyle
* @returns
*/
export const isShowSvg = (doc: DocTree) => {
return isNotBlank(doc.icon)
}
/**
* , 便
* @param doc
* @param viewStyle
*/
export const tags = (doc: DocTree) => {
let icons: any = []
doc.t.forEach((tag) => {
if (tag.toLocaleLowerCase() === 'subject') {
icons.unshift({ content: '专题', bgColor: '#939393', icon: 'bl-a-lowerrightpage-line' })
} else if (tag.toLocaleLowerCase() === 'toc') {
icons.push({ content: '目录', bgColor: '#565656' })
} else {
icons.push({ content: tag })
}
})
return icons
}

View File

@ -0,0 +1,447 @@
.doc-content-container {
height: 100%;
width: 1160px;
max-width: 1160px;
overflow-x: hidden;
overflow-y: scroll;
padding: 0 30px;
&::-webkit-scrollbar-thumb {
background-color: #ebebeb;
}
.article-name {
color: #4b4b4b;
font-weight: 700;
padding: 10px 0;
border-bottom: 1px solid #e5e5e5;
text-align: left;
position: relative;
font-size: 2em;
}
.bl-preview {
$borderRadius: 4px;
color: #4b4b4b;
font-size: inherit;
line-height: 1.6;
:deep(svg) {
max-width: 100% !important;
}
:deep(.katex > *) {
font-size: 1.2em !important;
}
::-webkit-scrollbar {
/* 滚动条里槽的背景色 */
background-color: transparent;
}
/* 定义滑块 内阴影+圆角 */
::-webkit-scrollbar-thumb {
border-radius: 4px;
background-color: #967777;
}
:deep(a) {
color: var(--el-color-primary);
font-weight: bold;
}
:deep(a.inner-link) {
border-bottom: 2px dashed var(--el-color-primary);
box-sizing: border-box;
padding: 0 4px;
text-decoration: none;
}
:deep(img) {
border-radius: $borderRadius;
max-width: 100%;
}
// 列表
:deep(h1) {
padding: 10px 0;
margin-top: 70px;
border-bottom: 1px solid #e5e5e5;
text-align: left;
position: relative;
font-size: 1.8em;
}
:deep(h2) {
font-size: 1.6em;
}
:deep(h3) {
font-size: 1.4em;
}
:deep(h4) {
font-size: 1.3em;
}
:deep(h5) {
font-size: 1.2em;
}
:deep(h6) {
font-size: 1.1em;
}
:deep(h1:first-child) {
margin-top: 20px;
}
:deep(li::marker) {
color: #989898;
}
/* 有序列表 */
:deep(ol) {
padding-left: 2em;
}
/* 无序列表 */
:deep(ul) {
padding-left: 2em;
}
/* checkbox */
:deep(li) {
input {
margin: 0 0 0 -1.4em;
}
&:has(> input)::marker {
content: none;
}
& :has(> p > input)::marker {
content: none;
}
}
:deep(hr) {
border-color: #ffffff75;
}
// 表格
:deep(table) {
border: 1px solid #939393;
box-sizing: border-box;
padding: 0;
border-spacing: 0;
margin: 10px 0;
max-width: 100%;
// table-layout: fixed;
table-layout: auto;
width: 100%;
thead {
background-color: #2b2b2b;
color: #d4d4d4;
code {
background-color: #000000;
}
tr {
th {
font-size: 16px;
padding: 10px;
border-right: 1px solid #6e6e6e;
}
th:last-child {
border: 0;
}
}
}
tbody {
tr {
td {
padding: 5px;
border-right: 1px solid #939393;
border-bottom: 1px solid #939393;
word-wrap: break-word;
width: auto;
}
td:last-child {
border-right: 0;
}
}
tr:last-child {
td {
border-bottom: 0;
}
}
}
}
:deep(.bl-table-container) {
border: 0;
thead {
display: none;
}
tbody {
td {
border: 0;
}
}
}
// 引用
:deep(blockquote) {
padding: 1px 10px;
margin: 10px 0;
color: #7d7d7d;
background-color: #f0f0f0;
border-left: 3px solid #bebebe;
border-radius: $borderRadius;
blockquote {
border: 1px solid #dedede;
border-left: 3px solid #bebebe;
}
}
:deep(.bl-blockquote-green) {
background-color: #edf8db;
border-left: 3px solid #bed609;
}
:deep(.bl-blockquote-yellow) {
background-color: #faf0d5;
border-left: 3px solid #efc75e;
}
:deep(.bl-blockquote-red) {
background-color: #fbe6e9;
border-left: 3px solid #ff9090;
}
:deep(.bl-blockquote-blue) {
background-color: #dfeefd;
border-left: 3px solid #81bbf8;
}
:deep(.bl-blockquote-purple) {
background-color: #ece4fb;
border-left: 3px solid #ba9bf2;
}
:deep(.bl-blockquote-black) {
background-color: rgba(0, 0, 0, 0.7);
border-left: 3px solid #000000;
}
// 单行代码块
:deep(code) {
font-family: 'Jetbrains Mono';
background-color: #dedede;
padding: 0px 4px;
border-radius: 3px;
margin: 0 3px;
word-wrap: break-word;
word-break: break-all;
}
// 代码块
:deep(pre) {
padding: 0 0 0 30px;
background-color: #2b2b2b;
border-radius: $borderRadius;
box-shadow: 2px 2px 5px rgb(76, 76, 76);
position: relative;
overflow: hidden;
* {
font-family: 'Jetbrains Mono' !important;
font-size: 0.95em;
}
::-webkit-scrollbar-thumb {
border-radius: 4px;
background-color: #5c5c5c;
}
.pre-copy {
position: absolute;
top: 5px;
right: 5px;
text-align: right;
z-index: 10;
color: #5c5c5c;
padding: 1px 8px;
border-radius: 4px;
cursor: pointer;
user-select: none;
}
.pre-copy:hover {
background-color: #1a1a1a;
color: #9d9d9d;
}
.pre-copy:active {
color: #e2e2e2;
}
ol {
margin: 0;
padding-left: 0;
position: absolute;
top: 15px;
left: 3px;
user-select: none;
li {
list-style: none;
.line-num {
width: 30px;
display: inline-block;
text-align: right;
padding-right: 10px;
color: #6a6a6a;
user-select: none;
}
}
}
code {
font-family: inherit;
background-color: inherit;
border-radius: 0;
margin: 0;
height: 100%;
width: 100%;
display: block;
padding: 15px 0 15px 0;
overflow: auto;
}
pre code.hljs {
display: block;
overflow-x: auto;
}
code.hljs {
text-shadow: none;
}
.hljs {
color: #a9b7c6;
background: #2b2b2b;
}
.hljs ::selection,
.hljs::selection {
// background-color: #323232;
// color: #a9b7c6;
}
.hljs-comment {
color: #606366;
}
.hljs-tag {
color: #a4a3a3;
}
.hljs-operator,
.hljs-punctuation,
.hljs-subst {
color: #a9b7c6;
}
.hljs-operator {
opacity: 0.7;
}
.hljs-bullet,
.hljs-deletion,
.hljs-name,
.hljs-selector-tag,
.hljs-template-variable,
.hljs-variable {
color: #4eade5;
}
.hljs-attr {
color: #cc7832;
}
.hljs-link,
.hljs-literal,
.hljs-number,
.hljs-symbol,
.hljs-variable.constant_ {
color: #689757;
}
.hljs-class .hljs-title,
.hljs-title,
.hljs-title.class_ {
color: #e4b568;
}
.hljs-strong {
font-weight: 700;
color: #bbb529;
}
.hljs-addition,
.hljs-code,
.hljs-string,
.hljs-title.class_.inherited__ {
color: #6a8759;
}
.hljs-built_in,
.hljs-doctag,
.hljs-keyword.hljs-atrule,
.hljs-quote,
.hljs-regexp {
color: #629755;
}
.hljs-attribute,
.hljs-function .hljs-title,
.hljs-section,
.hljs-title.function_,
.ruby .hljs-property {
color: #9876aa;
}
.diff .hljs-meta,
.hljs-keyword,
.hljs-template-tag,
.hljs-type {
color: #cc7832;
}
.hljs-emphasis {
color: #cc7832;
font-style: italic;
}
.hljs-meta,
.hljs-meta .hljs-keyword,
.hljs-meta .hljs-string {
color: #b4b428;
}
.hljs-meta .hljs-keyword,
.hljs-meta-keyword {
font-weight: 700;
}
}
}
}

View File

@ -0,0 +1,91 @@
.toc-container {
@include box(270px, 100%, 270px, 270px);
border-left: 1px solid #eeeeee;
overflow: auto;
transition: 0.3s;
&::-webkit-scrollbar-thumb {
background-color: #ebebeb;
}
.viewer-toc {
@include box(100%, 100%);
color: #5e5e5e;
padding: 10px;
z-index: 1000;
transition: 0.3s;
.toc-title {
@include box(100%, 40px);
@include font(25px, 700);
line-height: 40px;
margin-top: 10px;
padding-top: 10px;
border-top: 3px solid #bcbcbc;
}
.toc-subtitle {
width: 100%;
@include flex(row, flex-start, center);
@include font(12px);
color: #ababab;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
white-space: pre;
margin-top: 5px;
}
.toc-content {
@include font(14px);
width: 100%;
overflow-y: auto;
margin-top: 20px;
.toc-1,
.toc-2,
.toc-3,
.toc-4,
.toc-5,
.toc-6 {
min-height: 20px;
cursor: pointer;
&:hover {
font-weight: bold;
}
}
.toc-1 {
font-size: 1.1em;
margin-top: 5px;
&:first-child {
margin: 0;
padding: 0;
border: 0;
}
}
.toc-2 {
padding-left: 10px;
}
.toc-3 {
padding-left: 20px;
}
.toc-4 {
padding-left: 30px;
}
.toc-5 {
padding-left: 40px;
}
.toc-6 {
padding-left: 50px;
}
}
}
}

View File

@ -0,0 +1,87 @@
$icon-size: 17px;
.menu-item-wrapper {
width: 100%;
}
.doc-title {
@include flex(row, flex-start, flex-start);
width: 100%;
position: relative;
padding: 1px 0;
.doc-name {
@include flex(row, flex-start, flex-start);
@include ellipsis();
font-size: 14px;
font-weight: normal;
align-content: flex-start;
flex-wrap: wrap;
width: 100%;
.menu-icon,
.menu-icon-img {
@include box($icon-size, $icon-size, $icon-size, $icon-size, $icon-size, $icon-size);
margin-top: 4px;
margin-right: 8px;
}
.menu-icon-img {
border-radius: 2px;
}
.name-wrapper {
min-height: 23px;
line-height: 23px;
}
}
.sort {
position: absolute;
padding: 0 2px;
right: 2px;
top: 2px;
z-index: 10;
}
}
// 专题样式, 包括边框和文字样式
.subject-title {
@include flex(row, flex-start, flex-start);
box-shadow: 2px 2px 8px 1px var(--el-color-primary-light-8);
background: linear-gradient(135deg, var(--el-color-primary-light-7), var(--el-color-primary-light-8), var(--bl-html-color));
min-height: 44px;
max-width: 300px;
width: calc(100% - 10px);
padding: 4px 5px;
margin: 5px 0 5px 0;
border-radius: 7px;
.doc-name {
@include flex(row, flex-start, flex-start);
color: var(--el-color-primary);
align-content: flex-start;
flex-wrap: wrap;
width: 100%;
.menu-icon,
.menu-icon-img {
@include box($icon-size, $icon-size, $icon-size, $icon-size, $icon-size, $icon-size);
margin-right: 8px;
}
.menu-icon-img {
border-radius: 2px;
}
.name-wrapper {
max-width: calc(100% - 25px);
min-width: calc(100% - 25px);
}
}
.sort {
position: absolute;
right: 1px;
}
}

View File

@ -0,0 +1,50 @@
.doc-tree-container {
@include box(300px, 100%, 300px, 300px);
border-right: 1px solid var(--el-border-color);
transition: 0.1s;
.doc-tree {
@include box(100%, 100%);
height: 100%;
padding-right: 2px;
overflow-y: scroll;
--el-transition-duration: 0.2s; // 折叠展开的动画效果
:deep(.is-drop-inner) {
box-shadow: inset 0 0 1px 2px var(--el-color-primary);
border-radius: 4px;
}
:deep(.el-tree__drop-indicator) {
height: 2px;
}
:deep(.el-tree-node__expand-icon) {
height: 10px;
width: 10px;
color: var(--bl-text-doctree-color);
}
:deep(.is-current) {
& > .el-tree-node__content {
border-radius: 5px;
&:has(.menu-divider) {
background-color: transparent;
}
}
}
:deep(.el-tree-node__content) {
height: auto;
&:hover {
border-radius: 5px;
}
}
&::-webkit-scrollbar-thumb {
background-color: #ebebeb;
}
}
}

View File

@ -14,9 +14,6 @@
</div>
</div>
<div class="home-footer">
<div class="about-us">
<span>{{ blossom.SYS.VERSION + (isNotBlank(getEmail()) ? ' | 邮箱:' + getEmail() : '') }}</span>
</div>
<div class="custom-info">
<div v-if="isNotBlank(gwab())" v-html="gwab()"></div>
<div v-if="isNotBlank(ipc())" style="cursor: pointer" @click="openNew('https://beian.miit.gov.cn/')">

View File

@ -54,6 +54,10 @@ const toToc = (articleId: number) => {
padding: 20px;
overflow: scroll;
&::-webkit-scrollbar-thumb {
background-color: #414141;
}
.subject-item {
@include box(260px, 150px);
position: relative;