mirror of
https://github.com/blossom-editor/blossom
synced 2024-11-17 14:39:21 +08:00
Merge branch 'blossom-editor:dev' into dev
This commit is contained in:
commit
1c59f0ae08
17
README.md
17
README.md
@ -73,18 +73,11 @@ docker compose -f docker/compose/blossom-mysql8.yaml up -d
|
||||
|
||||
**感谢每一个位赞助者对 Blossom 的大力支持,Blossom 因为你们变得更好。**
|
||||
|
||||
| 赞助者 / Sponsors | 赞助者 / Sponsors | 赞助者 / Sponsors | 赞助者 / Sponsors |
|
||||
| :------------------- | :---------------- | :---------------- | :---------------- |
|
||||
| i just walked away🏃 | KalyanYang | dsx | 熊猫不是猫 |
|
||||
| 白驹过隙 | 支付宝用户-\*发 | 支付宝用户-\*琛 | 支付宝匿名用户 |
|
||||
| 疾风剑豪索隆 | 放羊的星星 | 立冬 | 给你一支烟 |
|
||||
| 林诗 | Criesgod | 支付宝用户-\*伟 | 黑醋栗和丁香 |
|
||||
| Mr_tg000 | Lucky | egil | Glimpse |
|
||||
| 支付宝用户-\*\*衡 | 支付宝用户-\*\*福 | 支付宝用户-\*\*盼 | 何其正 |
|
||||
| -A 明 | 开心。 | Please | 樹梢 |
|
||||
| 支付宝用户-\*\*耀 | Crius | 初五 | 一巷灯火深 |
|
||||
| 支付宝用户-\*\*鲲 | 木辰音 | 宁采臣 | 支付宝用户-\*\*雷 |
|
||||
| Klaus | | | |
|
||||
<p align="center">
|
||||
<a target="_blank" href="https://www.wangyunf.com/blossom-doc/guide/about/sponsor-list.html">
|
||||
<img alt="sponsors" src="https://www.wangyunf.com/bl/pic/home/bl/img/U1/pic/sponsor.svg">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
|
17
SPONSORS.md
17
SPONSORS.md
@ -2,15 +2,8 @@
|
||||
|
||||
感谢每一个位赞助者对 Blossom 的大力支持,Blossom 因为你们变得更好。
|
||||
|
||||
| 赞助者 / Sponsors | 赞助者 / Sponsors | 赞助者 / Sponsors | 赞助者 / Sponsors |
|
||||
| :------------------- | :---------------- | :---------------- | :---------------- |
|
||||
| i just walked away🏃 | KalyanYang | dsx | 熊猫不是猫 |
|
||||
| 白驹过隙 | 支付宝用户-\*发 | 支付宝用户-\*琛 | 支付宝匿名用户 |
|
||||
| 疾风剑豪索隆 | 放羊的星星 | 立冬 | 给你一支烟 |
|
||||
| 林诗 | Criesgod | 支付宝用户-\*伟 | 黑醋栗和丁香 |
|
||||
| Mr_tg000 | Lucky | egil | Glimpse |
|
||||
| 支付宝用户-\*\*衡 | 支付宝用户-\*\*福 | 支付宝用户-\*\*盼 | 何其正 |
|
||||
| -A 明 | 开心。 | Please | 樹梢 |
|
||||
| 支付宝用户-\*\*耀 | Crius | 初五 | 一巷灯火深 |
|
||||
| 支付宝用户-\*\*鲲 | 木辰音 | 宁采臣 | 支付宝用户-\*\*雷 |
|
||||
| Klaus | | | |
|
||||
<p align="center">
|
||||
<a target="_blank" href="https://www.wangyunf.com/blossom-doc/guide/about/sponsor-list.html">
|
||||
<img alt="sponsors" src="https://www.wangyunf.com/bl/pic/home/bl/img/U1/pic/sponsor.svg">
|
||||
</a>
|
||||
</p>
|
@ -47,7 +47,7 @@ public enum ParamEnum {
|
||||
/**
|
||||
* BLOSSOM 对象存储地址
|
||||
*/
|
||||
BLOSSOM_OBJECT_STORAGE_DOMAIN(false, 0,"http://www.xxx.com/"),
|
||||
BLOSSOM_OBJECT_STORAGE_DOMAIN(false, 0,"http://www.google.com/"),
|
||||
|
||||
/**
|
||||
* 服务器JWT加密字符串
|
||||
|
@ -41,7 +41,7 @@ public class PropertiesCheckListener implements ApplicationListener<ApplicationE
|
||||
"\n[CHECK] 数据库配置: {}" +
|
||||
"\n[CHECK] 数据库用户: {}" +
|
||||
"\n[CHECK] 数据库密码: {}" +
|
||||
"\n[CHECK] 文件前缀: {}, 非 [http://www.xxx.com/] 内容会强制覆盖后台配置" +
|
||||
"\n[CHECK] 文件前缀: {}, 非 [http://www.google.com/] 内容会强制覆盖后台配置" +
|
||||
"\n[CHECK] 文件存储: {}" +
|
||||
"\n[CHECK] 文件大小: {}" +
|
||||
"\n[CHECK] 授权时长: {}" +
|
||||
|
@ -139,6 +139,7 @@ public class ArticleController {
|
||||
public R<Long> insert(@Validated @RequestBody ArticleUpdReq req) {
|
||||
ArticleEntity article = req.to(ArticleEntity.class);
|
||||
article.setTags(DocUtil.toTagStr(req.getTags()));
|
||||
article.setUserId(AuthContext.getUserId());
|
||||
return R.ok(baseService.update(article));
|
||||
}
|
||||
|
||||
@ -166,6 +167,7 @@ public class ArticleController {
|
||||
@PostMapping("/upd/name")
|
||||
public R<?> updName(@Validated @RequestBody ArticleUpdNameReq name) {
|
||||
ArticleEntity article = name.to(ArticleEntity.class);
|
||||
article.setUserId(AuthContext.getUserId());
|
||||
baseService.update(article);
|
||||
return R.ok();
|
||||
}
|
||||
@ -187,6 +189,7 @@ public class ArticleController {
|
||||
}
|
||||
ArticleEntity article = req.to(ArticleEntity.class);
|
||||
article.setTags(DocUtil.toTagStr(tags));
|
||||
article.setUserId(AuthContext.getUserId());
|
||||
baseService.update(article);
|
||||
return R.ok(tags);
|
||||
}
|
||||
@ -207,7 +210,9 @@ public class ArticleController {
|
||||
*/
|
||||
@PostMapping("/star")
|
||||
public R<Long> star(@Validated @RequestBody ArticleStarReq req) {
|
||||
return R.ok(baseService.update(req.to(ArticleEntity.class)));
|
||||
ArticleEntity article = req.to(ArticleEntity.class);
|
||||
article.setUserId(AuthContext.getUserId());
|
||||
return R.ok(baseService.update(article));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -221,12 +226,11 @@ public class ArticleController {
|
||||
public void download(@RequestParam("id") Long id, HttpServletResponse response) throws IOException {
|
||||
ArticleEntity article = baseService.selectById(id, false, true, false, AuthContext.getUserId());
|
||||
if (StrUtil.isBlank(article.getMarkdown())) {
|
||||
throw new IllegalArgumentException("文章内容为空,无法导出");
|
||||
article.setMarkdown("文章无内容");
|
||||
}
|
||||
try (InputStream is = new ByteArrayInputStream(article.getMarkdown().getBytes(StandardCharsets.UTF_8));
|
||||
BufferedInputStream bis = new BufferedInputStream(is)) {
|
||||
String filename = URLEncodeUtil.encode(article.getName() + ".md");
|
||||
|
||||
DownloadUtil.forceDownload(response, bis, filename);
|
||||
}
|
||||
}
|
||||
@ -242,7 +246,7 @@ public class ArticleController {
|
||||
public void downloadHtml(@RequestParam("id") Long id, HttpServletResponse response) throws IOException {
|
||||
ArticleEntity article = baseService.selectById(id, false, false, true, AuthContext.getUserId());
|
||||
if (StrUtil.isBlank(article.getHtml())) {
|
||||
throw new IllegalArgumentException("文章内容为空,无法导出");
|
||||
article.setHtml("<span>文章无内容</span>");
|
||||
}
|
||||
String reportHtml = ArticleUtil.toHtml(article, userService.selectById(AuthContext.getUserId()));
|
||||
try (InputStream is = new ByteArrayInputStream(reportHtml.getBytes(StandardCharsets.UTF_8));
|
||||
@ -270,9 +274,11 @@ public class ArticleController {
|
||||
String content = new String(file.getBytes(), StandardCharsets.UTF_8);
|
||||
ArticleEntity article = new ArticleEntity();
|
||||
article.setMarkdown(content);
|
||||
article.setVersion(1);
|
||||
article.setPid(pid);
|
||||
article.setUserId(AuthContext.getUserId());
|
||||
article.setName(FileUtil.getPrefix(file.getOriginalFilename()));
|
||||
article.setWords(ArticleUtil.statWords(content));
|
||||
baseService.insert(article);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
|
@ -166,6 +166,7 @@ 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());
|
||||
return req.getId();
|
||||
}
|
||||
|
||||
@ -212,8 +213,10 @@ public class ArticleService extends ServiceImpl<ArticleMapper, ArticleEntity> {
|
||||
baseMapper.deleteById(id);
|
||||
// 删除公开文章
|
||||
openMapper.delById(id);
|
||||
// 删除引用
|
||||
// 删除主动引用
|
||||
referenceService.delete(id);
|
||||
// 将被动引用中的名称修改为未知
|
||||
referenceService.updateToUnknown(userId, id);
|
||||
// 删除访问记录
|
||||
viewService.delete(id);
|
||||
}
|
||||
|
@ -1,23 +1,33 @@
|
||||
package com.blossom.backend.server.article.recycle;
|
||||
|
||||
|
||||
import cn.hutool.core.net.URLEncodeUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.blossom.backend.base.auth.AuthContext;
|
||||
import com.blossom.backend.server.article.recycle.pojo.ArticleRecycleEntity;
|
||||
import com.blossom.backend.server.article.recycle.pojo.ArticleRecycleListRes;
|
||||
import com.blossom.backend.server.article.recycle.pojo.ArticleRecycleRestoreReq;
|
||||
import com.blossom.backend.server.utils.DownloadUtil;
|
||||
import com.blossom.common.base.pojo.R;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文章回收站 [A#Recycle]
|
||||
*
|
||||
* @author xzzz
|
||||
* @since 1.10.0
|
||||
* @order 7
|
||||
* @since 1.10.0
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@ -44,7 +54,27 @@ public class ArticleRecycleController {
|
||||
*/
|
||||
@PostMapping("/restore")
|
||||
public R<?> restore(@Validated @RequestBody ArticleRecycleRestoreReq req) {
|
||||
baseService.restore(req.getId());
|
||||
baseService.restore(AuthContext.getUserId(), req.getId());
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文章
|
||||
*
|
||||
* @param id 文章ID
|
||||
* @param response 文章流
|
||||
* @apiNote 返回流
|
||||
*/
|
||||
@GetMapping("/download")
|
||||
public void download(@RequestParam("id") Long id, HttpServletResponse response) throws IOException {
|
||||
ArticleRecycleEntity article = baseService.selectById(id);
|
||||
if (StrUtil.isBlank(article.getMarkdown())) {
|
||||
article.setMarkdown("文章无内容");
|
||||
}
|
||||
try (InputStream is = new ByteArrayInputStream(article.getMarkdown().getBytes(StandardCharsets.UTF_8));
|
||||
BufferedInputStream bis = new BufferedInputStream(is)) {
|
||||
String filename = URLEncodeUtil.encode(article.getName() + ".md");
|
||||
DownloadUtil.forceDownload(response, bis, filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import com.blossom.backend.base.param.pojo.ParamEntity;
|
||||
import com.blossom.backend.base.search.EnableIndex;
|
||||
import com.blossom.backend.base.search.message.IndexMsgTypeEnum;
|
||||
import com.blossom.backend.server.article.recycle.pojo.ArticleRecycleEntity;
|
||||
import com.blossom.backend.server.article.reference.ArticleReferenceService;
|
||||
import com.blossom.backend.server.folder.FolderService;
|
||||
import com.blossom.backend.server.folder.pojo.FolderEntity;
|
||||
import com.blossom.common.base.util.DateUtils;
|
||||
@ -34,6 +35,7 @@ public class ArticleRecycleService extends ServiceImpl<ArticleRecycleMapper, Art
|
||||
|
||||
private final FolderService folderService;
|
||||
private final ParamService paramService;
|
||||
private final ArticleReferenceService referenceService;
|
||||
|
||||
|
||||
/**
|
||||
@ -45,6 +47,15 @@ public class ArticleRecycleService extends ServiceImpl<ArticleRecycleMapper, Art
|
||||
return baseMapper.listAll(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查询
|
||||
*
|
||||
* @param id 文章ID
|
||||
*/
|
||||
public ArticleRecycleEntity selectById(Long id) {
|
||||
return baseMapper.selectById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 还原数据
|
||||
*
|
||||
@ -52,7 +63,7 @@ public class ArticleRecycleService extends ServiceImpl<ArticleRecycleMapper, Art
|
||||
*/
|
||||
@EnableIndex(type = IndexMsgTypeEnum.ADD, id = "#id")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void restore(Long id) {
|
||||
public void restore(Long userId, Long id) {
|
||||
ArticleRecycleEntity article = baseMapper.selectById(id);
|
||||
FolderEntity folder = folderService.selectById(article.getPid());
|
||||
if (ObjUtil.isNull(folder)) {
|
||||
@ -61,10 +72,13 @@ public class ArticleRecycleService extends ServiceImpl<ArticleRecycleMapper, Art
|
||||
baseMapper.restore(id, folder.getId());
|
||||
}
|
||||
baseMapper.deleteById(id);
|
||||
// 将被动引用中的未知文章名修改为正常文章名
|
||||
referenceService.updateToKnown(userId, id, article.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 每天凌晨4点执行
|
||||
*
|
||||
* @Scheduled(cron = "0 0/1 * * * ?")
|
||||
*/
|
||||
@Scheduled(cron = "0 0 04 * * ?")
|
||||
|
@ -0,0 +1,30 @@
|
||||
package com.blossom.backend.server.article.reference;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
public enum ArticleReferenceEnum {
|
||||
/**
|
||||
* 图片
|
||||
*/
|
||||
FILE(10),
|
||||
/**
|
||||
* 内部文章
|
||||
*/
|
||||
INNER(11),
|
||||
/**
|
||||
* 未知内部文章
|
||||
*/
|
||||
INNER_UNKNOWN(12),
|
||||
|
||||
/**
|
||||
* 外部文章
|
||||
*/
|
||||
OUTSIDE(21);
|
||||
|
||||
@Getter
|
||||
private final Integer type;
|
||||
|
||||
ArticleReferenceEnum(Integer type) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
@ -40,4 +40,25 @@ public interface ArticleReferenceMapper extends BaseMapper<ArticleReferenceEntit
|
||||
* @param articleId 文章ID
|
||||
*/
|
||||
List<ArticleReferenceEntity> listGraph(@Param("inner") Boolean inner, @Param("userId") Long userId, @Param("articleId") Long articleId);
|
||||
|
||||
/**
|
||||
* 修改 sourceName
|
||||
*/
|
||||
void updateSourceName(@Param("userId") Long userId, @Param("sourceId") Long sourceId, @Param("sourceName") String sourceName);
|
||||
|
||||
/**
|
||||
* 修改 targetName
|
||||
*/
|
||||
void updateTargetName(@Param("userId") Long userId, @Param("targetId") Long targetId, @Param("targetName") String targetName);
|
||||
|
||||
/**
|
||||
* 被引用的文章修改为未知
|
||||
*/
|
||||
void updateToUnknown(@Param("userId") Long userId, @Param("targetId") Long targetId, @Param("targetName") String targetName);
|
||||
|
||||
/**
|
||||
* 被引用的文章修改为未知
|
||||
*/
|
||||
void updateToKnown(@Param("userId") Long userId, @Param("targetId") Long targetId, @Param("targetName") String targetName);
|
||||
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package com.blossom.backend.server.article.reference;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
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.reference.pojo.ArticleReferenceEntity;
|
||||
@ -29,11 +30,15 @@ public class ArticleReferenceService extends ServiceImpl<ArticleReferenceMapper,
|
||||
|
||||
/**
|
||||
* 文章引用记录
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param sourceId 引用源
|
||||
* @param sourceName 引用源名称
|
||||
* @param references 目标
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void bind(Long userId, Long sourceId, String sourceName, List<ArticleReferenceReq> references) {
|
||||
delete(sourceId);
|
||||
|
||||
// 没有图片, 则不保存
|
||||
if (CollUtil.isEmpty(references)) {
|
||||
return;
|
||||
@ -47,11 +52,51 @@ public class ArticleReferenceService extends ServiceImpl<ArticleReferenceMapper,
|
||||
baseMapper.insertList(refs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文章修改为内部未知文章
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param targetId 目标文章ID
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateToUnknown(Long userId, Long targetId) {
|
||||
baseMapper.updateToUnknown(userId, targetId, "未知文章-" + targetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文章引用修改为内部具名文章
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param targetId 目标文章ID
|
||||
* @param name 文章名称
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateToKnown(Long userId, Long targetId, String name) {
|
||||
baseMapper.updateToKnown(userId, targetId, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部链接修改名称时, 同时修改双链中的名称
|
||||
*
|
||||
* @param articleId ID
|
||||
* @param name 名称
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateInnerName(Long userId, Long articleId, String name) {
|
||||
if (articleId <= 0 || StrUtil.isBlank(name)) {
|
||||
return;
|
||||
}
|
||||
baseMapper.updateSourceName(userId, articleId, name);
|
||||
baseMapper.updateTargetName(userId, articleId, name);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 删除引用
|
||||
*
|
||||
* @param articleId 文章ID
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long articleId) {
|
||||
LambdaQueryWrapper<ArticleReferenceEntity> where = new LambdaQueryWrapper<>();
|
||||
where.eq(ArticleReferenceEntity::getSourceId, articleId);
|
||||
@ -89,6 +134,8 @@ public class ArticleReferenceService extends ServiceImpl<ArticleReferenceMapper,
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
List<ArticleReferenceEntity> all = baseMapper.listGraph(onlyInner, userId, articleId);
|
||||
if (CollUtil.isEmpty(all)) {
|
||||
result.put("nodes",new String[0]);
|
||||
result.put("links",new String[0]);
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -100,14 +147,15 @@ public class ArticleReferenceService extends ServiceImpl<ArticleReferenceMapper,
|
||||
|
||||
Set<Node> nodes = new HashSet<>();
|
||||
source.forEach((id, list) -> {
|
||||
Node node = new Node(list.get(0).getSourceName(), 11);
|
||||
Node node = new Node(list.get(0).getSourceName(), ArticleReferenceEnum.INNER.getType());
|
||||
node.setInner(true);
|
||||
node.setArtId(id);
|
||||
nodes.add(node);
|
||||
});
|
||||
target.forEach((name, list) -> {
|
||||
Node node = new Node(list.get(0).getTargetName(), list.get(0).getType());
|
||||
if (list.get(0).getType().equals(11)) {
|
||||
final Integer type = list.get(0).getType();
|
||||
Node node = new Node(list.get(0).getTargetName(), type);
|
||||
if (type.equals(ArticleReferenceEnum.INNER.getType()) || type.equals(ArticleReferenceEnum.INNER_UNKNOWN.getType())) {
|
||||
node.setInner(true);
|
||||
node.setArtId(list.get(0).getTargetId());
|
||||
} else {
|
||||
|
@ -41,19 +41,34 @@ public class TodoService extends ServiceImpl<TodoMapper, TodoEntity> {
|
||||
public TodoGroupRes listTodo() {
|
||||
List<TodoEntity> todos = baseMapper.listTodo(AuthContext.getUserId());
|
||||
TodoGroupRes res = TodoGroupRes.build();
|
||||
for (TodoEntity todo : todos) {
|
||||
TodoGroupRes.TodoGroup group = todo.to(TodoGroupRes.TodoGroup.class);
|
||||
if (TodoTypeEnum.DAY.getType().equals(todo.getTodoType())) {
|
||||
|
||||
Map<String, List<TodoEntity>> map = todos.stream().collect(Collectors.groupingBy(TodoEntity::getTodoId));
|
||||
map.forEach((todoId, data) -> {
|
||||
TodoGroupRes.TodoGroup group = data.get(0).to(TodoGroupRes.TodoGroup.class);
|
||||
Map<String, List<TodoEntity>> taskStatusMap = data.stream().collect(Collectors.groupingBy(TodoEntity::getTaskStatus));
|
||||
int w = 0, p = 0, c = 0;
|
||||
if (CollUtil.isNotEmpty(taskStatusMap.get(TaskStatusEnum.WAITING.name()))) {
|
||||
w = taskStatusMap.get(TaskStatusEnum.WAITING.name()).get(0).getTaskCount();
|
||||
}
|
||||
if (CollUtil.isNotEmpty(taskStatusMap.get(TaskStatusEnum.PROCESSING.name()))) {
|
||||
p = taskStatusMap.get(TaskStatusEnum.PROCESSING.name()).get(0).getTaskCount();
|
||||
}
|
||||
if (CollUtil.isNotEmpty(taskStatusMap.get(TaskStatusEnum.COMPLETED.name()))) {
|
||||
c = taskStatusMap.get(TaskStatusEnum.COMPLETED.name()).get(0).getTaskCount();
|
||||
}
|
||||
group.setTaskCountStat(String.format("%d|%d|%d", w, p, c));
|
||||
|
||||
if (TodoTypeEnum.DAY.getType().equals(data.get(0).getTodoType())) {
|
||||
res.getTodoDays().put(group.getTodoId(), group);
|
||||
} else {
|
||||
// 未完成的阶段性事项
|
||||
if (TodoStatusEnum.OPEN.getType().equals(todo.getTodoStatus())) {
|
||||
if (TodoStatusEnum.OPEN.getType().equals(data.get(0).getTodoStatus())) {
|
||||
res.getTaskPhased().add(group);
|
||||
} else {
|
||||
res.getTaskPhasedClose().add(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
|
@ -55,6 +55,10 @@ public class TodoGroupRes {
|
||||
* 任务数量
|
||||
*/
|
||||
private Integer taskCount;
|
||||
/**
|
||||
* 任务数量说明
|
||||
*/
|
||||
private String taskCountStat;
|
||||
/**
|
||||
* 事项状态 1:完成 | 2:未完成
|
||||
*/
|
||||
|
@ -84,6 +84,6 @@ project:
|
||||
os-type: blossom
|
||||
blos:
|
||||
# 请以 /pic 结尾, 如果你在 nginx 中配置有代理, 注意别忘了添加你的代理路径
|
||||
domain: "http://www.xxx.com/"
|
||||
domain: "http://www.google.com/"
|
||||
# 请以 / 开头, / 结尾, 简短的路径在文章中有更好的显示效果, 过长一定程度会使文章内容混乱
|
||||
default-path: "/home/bl/img/"
|
@ -76,6 +76,6 @@ project:
|
||||
blos:
|
||||
# 请以 /pic 结尾, 如果你在 nginx 中配置有代理, 注意别忘了添加你的代理路径
|
||||
# 注意:在下方示例中, /bl 即为 nginx 反向代理路径, 如果你的访问路径中不包含反向代理或路径不同, 请酌情删除或修改
|
||||
domain: "http://www.xxx.com/"
|
||||
domain: "http://www.google.com/"
|
||||
# 请以 / 开头, / 结尾, 简短的路径在文章中有更好的显示效果, 过长一定程度会使文章内容混乱
|
||||
default-path: "/home/bl/img/"
|
@ -78,6 +78,7 @@
|
||||
upd_time = now()
|
||||
</set>
|
||||
where id = #{id}
|
||||
and user_id = #{userId}
|
||||
</update>
|
||||
|
||||
<!-- 根据ID修改 -->
|
||||
|
@ -48,14 +48,43 @@
|
||||
from blossom_article_reference
|
||||
where user_id = #{userId}
|
||||
<if test="inner == true">
|
||||
and type = 11
|
||||
and type in(11,12)
|
||||
</if>
|
||||
<if test="inner == false">
|
||||
and type in (11,21)
|
||||
and type in (11,12,21)
|
||||
</if>
|
||||
<if test="articleId != null">
|
||||
and (source_id = #{articleId} or target_id = #{articleId})
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<update id="updateSourceName">
|
||||
update blossom_article_reference
|
||||
set source_name = #{sourceName}
|
||||
where source_id = #{sourceId}
|
||||
and user_id = #{userId}
|
||||
</update>
|
||||
|
||||
<update id="updateTargetName">
|
||||
update blossom_article_reference
|
||||
set target_name = #{targetName}
|
||||
where target_Id = #{targetId}
|
||||
and user_id = #{userId}
|
||||
</update>
|
||||
|
||||
<update id="updateToUnknown">
|
||||
update blossom_article_reference
|
||||
set target_name = #{targetName},
|
||||
type = 12
|
||||
where target_Id = #{targetId}
|
||||
and user_id = #{userId}
|
||||
</update>
|
||||
|
||||
<update id="updateToKnown">
|
||||
update blossom_article_reference
|
||||
set target_name = #{targetName},
|
||||
type = 11
|
||||
where target_Id = #{targetId}
|
||||
and user_id = #{userId}
|
||||
</update>
|
||||
</mapper>
|
@ -47,12 +47,12 @@
|
||||
</select>
|
||||
|
||||
<select id="listTodo" resultType="com.blossom.backend.server.todo.pojo.TodoEntity">
|
||||
select min(id) as id,todo_id,todo_name,todo_type,todo_status,
|
||||
select min(id) as id,todo_id,todo_name,todo_type,todo_status,task_status,
|
||||
count(*) as taskCount
|
||||
from blossom_todo
|
||||
where user_id = #{userId}
|
||||
group by todo_id,todo_name,todo_type,todo_status
|
||||
order by id desc
|
||||
from blossom_todo
|
||||
where user_id = #{userId}
|
||||
group by todo_id,todo_name,todo_type,todo_status,task_status
|
||||
order by id desc
|
||||
</select>
|
||||
|
||||
<select id="selectByTodoId" resultType="com.blossom.backend.server.todo.pojo.TodoEntity">
|
||||
|
@ -189,7 +189,7 @@ INSERT INTO base_sys_param (id, param_name, param_value, param_desc, open_state,
|
||||
SELECT *
|
||||
FROM (select 101 as id,
|
||||
'BLOSSOM_OBJECT_STORAGE_DOMAIN' as param_name,
|
||||
'http://www.xxx.com/' as param_value,
|
||||
'http://www.google.com/' as param_value,
|
||||
'BLOSSOM 对象存储地址' as param_desc,
|
||||
1 as open_state,
|
||||
CURRENT_TIMESTAMP as cre_time,
|
||||
|
@ -473,6 +473,11 @@ export const articleRecycleRestoreApi = (data?: object): Promise<any> => {
|
||||
return rq.post('/article/recycle/restore', data)
|
||||
}
|
||||
|
||||
export const articleRecycleDownloadApi = (params?: object): Promise<any> => {
|
||||
let config = { params: params, responseType: 'blob' }
|
||||
return rq.get('/article/recycle/download', config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章全文搜索
|
||||
*/
|
||||
|
@ -279,8 +279,8 @@ html.dark {
|
||||
--bl-preview-pre-box-shadow: inset 0 0 8px #9e9e9e; */
|
||||
|
||||
/* 全局滚动条样式 */
|
||||
--bl-scrollbar-color: #0000001a;
|
||||
--bl-scrollbar-color-hover: #0000002a;
|
||||
--bl-scrollbar-color: #bfbfbf66;
|
||||
--bl-scrollbar-color-hover: #8484848a;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
|
@ -1,8 +1,8 @@
|
||||
@font-face {
|
||||
font-family: "iconbl"; /* Project id 4118609 */
|
||||
src: url('iconfont.woff2?t=1704477350520') format('woff2'),
|
||||
url('iconfont.woff?t=1704477350520') format('woff'),
|
||||
url('iconfont.ttf?t=1704477350520') format('truetype');
|
||||
src: url('iconfont.woff2?t=1705742748603') format('woff2'),
|
||||
url('iconfont.woff?t=1705742748603') format('woff'),
|
||||
url('iconfont.ttf?t=1705742748603') format('truetype');
|
||||
}
|
||||
|
||||
.iconbl {
|
||||
@ -13,6 +13,10 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.bl-scroll:before {
|
||||
content: "\e610";
|
||||
}
|
||||
|
||||
.bl-and:before {
|
||||
content: "\e71b";
|
||||
}
|
||||
|
File diff suppressed because one or more lines are too long
@ -5,6 +5,13 @@
|
||||
"css_prefix_text": "bl-",
|
||||
"description": "",
|
||||
"glyphs": [
|
||||
{
|
||||
"icon_id": "9691336",
|
||||
"name": "滚轮滚动",
|
||||
"font_class": "scroll",
|
||||
"unicode": "e610",
|
||||
"unicode_decimal": 58896
|
||||
},
|
||||
{
|
||||
"icon_id": "8990451",
|
||||
"name": "相交",
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -58,4 +58,4 @@
|
||||
&-bottom:hover:after {
|
||||
margin-top: 9px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -117,3 +117,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-popper.is-small {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
@ -36,17 +36,19 @@
|
||||
ref="ButtonRef"
|
||||
v-click-outside="onClickOutside"></div>
|
||||
|
||||
<div class="iconbl bl-a-colorpalette-line" @click="themeStrore.show()"></div>
|
||||
<el-tooltip content="主题配置" popper-class="is-small" transition="none" effect="light" placement="top" :show-after="0" :hide-after="0">
|
||||
<div class="iconbl bl-a-colorpalette-line" @click="themeStrore.show()"></div>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="查看图标" effect="light" placement="top" :show-after="1000" :hide-after="0" :auto-close="2000">
|
||||
<el-tooltip content="所有图标" popper-class="is-small" transition="none" effect="light" placement="top" :show-after="0" :hide-after="0">
|
||||
<div class="iconbl bl-a-radiochoose-line" @click="toRoute('/iconListIndex')"></div>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="显示网页收藏" effect="light" placement="top" :show-after="1000" :hide-after="0" :auto-close="2000">
|
||||
<el-tooltip content="网页收藏" popper-class="is-small" transition="none" effect="light" placement="top" :show-after="0" :hide-after="0">
|
||||
<div class="iconbl bl-folding-line" @click="isShowWebDrawer = !isShowWebDrawer"></div>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="最佳窗口大小" effect="light" placement="top" :show-after="1000" :hide-after="0" :auto-close="2000">
|
||||
<el-tooltip content="最佳窗口大小" popper-class="is-small" transition="none" effect="light" placement="top" :show-after="0" :hide-after="0">
|
||||
<div v-if="isElectron()" :class="['iconbl bl-computer-line', isWindows() ? '' : 'electron-mac-last']" @click="setBestSize"></div>
|
||||
</el-tooltip>
|
||||
|
||||
|
@ -20,7 +20,7 @@ const DEFAULT_WEB_ARTICLE_URL = 'https://www.domain.com/blossom/#/articles?artic
|
||||
/**
|
||||
* 博客的默认地址
|
||||
*/
|
||||
const DEFAULT_BLOSSOM_OBJECT_STORAGE_DOMAIN = 'http://www.xxx.com/'
|
||||
const DEFAULT_BLOSSOM_OBJECT_STORAGE_DOMAIN = 'http://www.google.com/'
|
||||
|
||||
/**
|
||||
* 登录状态枚举
|
||||
|
@ -53,11 +53,48 @@
|
||||
<div v-if="!curArticle" class="ep-placeholder">
|
||||
<ArticleIndexPlaceholder></ArticleIndexPlaceholder>
|
||||
</div>
|
||||
<div class="operator" ref="EditorOperatorRef">
|
||||
<el-tooltip
|
||||
:content="'同步滚动:' + (editorOperator.sycnScroll ? '开启' : '关闭')"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="right"
|
||||
transition="none"
|
||||
:show-after="500"
|
||||
:hide-after="0"
|
||||
:show-arrow="false">
|
||||
<div
|
||||
class="iconbl bl-scroll"
|
||||
:style="{ color: editorOperator.sycnScroll ? 'var(--el-color-primary-light-3)' : '' }"
|
||||
@click="handleSyncScroll"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="前往顶部"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="right"
|
||||
transition="none"
|
||||
:show-after="500"
|
||||
:hide-after="0"
|
||||
:show-arrow="false">
|
||||
<div class="iconbl bl-a-doubleonline-line" @click="scrollTop"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="前往底部"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="right"
|
||||
transition="none"
|
||||
:show-after="500"
|
||||
:hide-after="0"
|
||||
:show-arrow="false">
|
||||
<div class="iconbl bl-a-doubleunderline-line" @click="scrollBottom"></div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="gutter-holder" ref="GutterHolderRef"></div>
|
||||
<div class="editor-codemirror" ref="EditorRef" @click.right="handleEditorClickRight"></div>
|
||||
<div class="resize-divider" ref="ResizeDividerRef"></div>
|
||||
<div class="preview-marked bl-preview" ref="PreviewRef" v-html="articleHtml"></div>
|
||||
<el-backtop target=".editor-codemirror" :right="50" :bottom="70"> <div class="iconbl bl-send-line backtop"></div> </el-backtop>
|
||||
</div>
|
||||
|
||||
<!-- status -->
|
||||
@ -69,12 +106,10 @@
|
||||
<div :class="['bl-preview-toc-absolute', tocsExpand ? 'is-expand-open' : 'is-expand-close']" ref="TocRef">
|
||||
<div class="toc-title" ref="TocTitleRef">
|
||||
目录
|
||||
<span v-show="tocsExpand" style="font-size: 10px">({{ isMacOS() ? 'Cmd' : 'Alt' }}+2 可隐藏)</span>
|
||||
<span v-show="tocsExpand" style="font-size: 10px">({{ keymaps.hideToc }} 可隐藏)</span>
|
||||
</div>
|
||||
<div class="toc-content" v-show="tocsExpand">
|
||||
<div v-for="toc in articleToc" :key="toc.index" :class="[toc.clazz]" @click="toScroll(toc.level, toc.content)">
|
||||
<span v-html="toc.content"></span>
|
||||
</div>
|
||||
<div v-for="toc in articleToc" :key="toc.id" :class="[toc.clazz]" @click="toScroll(toc.id)" v-html="toc.content"></div>
|
||||
</div>
|
||||
<div class="img-title">
|
||||
引用图片
|
||||
@ -156,39 +191,33 @@ import { articleInfoApi, articleUpdContentApi, uploadFileApiUrl } from '@rendere
|
||||
// utils
|
||||
import { Local } from '@renderer/assets/utils/storage'
|
||||
import { isBlank, isNull } from '@renderer/assets/utils/obj'
|
||||
import { sleep, isMacOS, isElectron } from '@renderer/assets/utils/util'
|
||||
import { sleep, isElectron } from '@renderer/assets/utils/util'
|
||||
import { openExtenal, writeText, readText, openNewArticleWindow } from '@renderer/assets/utils/electron'
|
||||
import { formartMarkdownTable } from '@renderer/assets/utils/format-table'
|
||||
// component
|
||||
// import PictureViewerInfo from '@renderer/views/picture/PictureViewerInfo.vue'
|
||||
import ArticleTreeDocs from './ArticleTreeDocs.vue'
|
||||
import ArticleIndexPlaceholder from './ArticleIndexPlaceholder.vue'
|
||||
import EditorTools from './EditorTools.vue'
|
||||
// import EditorStatus from './EditorStatus.vue'
|
||||
// ts
|
||||
import hotkeys from 'hotkeys-js'
|
||||
import Notify from '@renderer/scripts/notify'
|
||||
import { useDraggable } from '@renderer/scripts/draggable'
|
||||
import type { shortcutFunc } from '@renderer/scripts/shortcut-register'
|
||||
import { treeToInfo, provideKeyDocInfo, provideKeyCurArticleInfo } from '@renderer/views/doc/doc'
|
||||
import { TempTextareaKey, ArticleReference, DocEditorStyle } from './scripts/article'
|
||||
import { treeToInfo, provideKeyDocInfo, provideKeyCurArticleInfo, isArticle } from '@renderer/views/doc/doc'
|
||||
import { TempTextareaKey, ArticleReference, DocEditorStyle, parseTocAsync } from './scripts/article'
|
||||
import type { Toc } from './scripts/article'
|
||||
import { beforeUpload, onError, picCacheWrapper, picCacheRefresh, uploadForm, uploadDate } from '@renderer/views/picture/scripts/picture'
|
||||
import { useResize } from './scripts/editor-preview-resize'
|
||||
// codemirror
|
||||
import { CmWrapper } from './scripts/codemirror'
|
||||
// marked
|
||||
import marked, {
|
||||
renderBlockquote,
|
||||
renderCode,
|
||||
renderCodespan,
|
||||
renderHeading,
|
||||
renderImage,
|
||||
renderTable,
|
||||
tokenizerCodespan,
|
||||
renderLink
|
||||
} from './scripts/markedjs'
|
||||
import marked, { renderBlockquote, renderCode, renderCodespan, renderHeading, renderImage, renderTable, renderLink } from './scripts/markedjs'
|
||||
import { EPScroll } from './scripts/editor-preview-scroll'
|
||||
import { useArticleHtmlEvent } from './scripts/article-html-event'
|
||||
import { shallowRef } from 'vue'
|
||||
import { keymaps } from './scripts/editor-tools'
|
||||
|
||||
//#region -- mounted
|
||||
|
||||
const PictureViewerInfo = defineAsyncComponent(() => import('@renderer/views/picture/PictureViewerInfo.vue'))
|
||||
// const EditorTools = defineAsyncComponent(() => import('./EditorTools.vue'))
|
||||
@ -223,7 +252,6 @@ onDeactivated(() => {
|
||||
unbindKeys()
|
||||
})
|
||||
|
||||
//#region ----------------------------------------< panin store >--------------------------------------
|
||||
const userStore = useUserStore()
|
||||
const serverStore = useServerStore()
|
||||
const { editorStyle } = useConfigStore()
|
||||
@ -236,14 +264,19 @@ watch(
|
||||
setNewState('')
|
||||
}
|
||||
)
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 公共参数和页面动态样式 >--------------------------------------
|
||||
const GutterHolderRef = ref() // editor gutter holder
|
||||
const EditorRef = ref() // editor
|
||||
const ResizeDividerRef = ref() // editor&preview resize dom
|
||||
const EditorOperatorRef = ref()
|
||||
const PreviewRef = ref() // html 预览
|
||||
|
||||
const editorOperator = ref({
|
||||
syncParse: true,
|
||||
sycnScroll: true
|
||||
})
|
||||
/**
|
||||
* 文档列表的展开和收起
|
||||
*/
|
||||
@ -266,7 +299,8 @@ const changeEditorPreviewStyle = () => {
|
||||
GutterHolderRef.value.style.width = '0px'
|
||||
EditorRef.value.style.width = '0px'
|
||||
PreviewRef.value.style.width = '100%'
|
||||
PreviewRef.value.style.padding = '10px 20px 0 20px'
|
||||
PreviewRef.value.style.padding = '10px 20px 0 30px'
|
||||
EditorOperatorRef.value.style.display = 'none'
|
||||
return
|
||||
}
|
||||
if (editorFullScreen) {
|
||||
@ -274,12 +308,15 @@ const changeEditorPreviewStyle = () => {
|
||||
EditorRef.value.style.width = 'calc(100% - 6px)'
|
||||
PreviewRef.value.style.width = '0'
|
||||
PreviewRef.value.style.padding = '0'
|
||||
EditorOperatorRef.value.style.display = 'none'
|
||||
return
|
||||
}
|
||||
GutterHolderRef.value.style.width = '50px'
|
||||
EditorRef.value.style.width = '50%'
|
||||
PreviewRef.value.style.width = '50%'
|
||||
PreviewRef.value.style.padding = '10px 20px 0 20px'
|
||||
PreviewRef.value.style.padding = '10px 20px 0 30px'
|
||||
EditorOperatorRef.value.style.display = 'block'
|
||||
EditorOperatorRef.value.style.left = 'calc(50% - 0.5px)'
|
||||
}
|
||||
/**
|
||||
* 临时文本框
|
||||
@ -320,6 +357,7 @@ const exitView = () => {
|
||||
autoSave()
|
||||
}
|
||||
|
||||
useResize(EditorRef, PreviewRef, ResizeDividerRef, EditorOperatorRef)
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 图片管理 >--------------------------------------
|
||||
@ -354,6 +392,41 @@ const uploadFile = (file: File) => {
|
||||
cmw.insertBlockCommand(`\n![${file.name}](${url})\n`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传回调
|
||||
* @param event DragEvent | ClipboardEvent
|
||||
*/
|
||||
const uploadFileCallback = async (event: DragEvent | ClipboardEvent) => {
|
||||
if (!isArticle(curArticle.value)) return
|
||||
|
||||
/**
|
||||
* 拖拽上传
|
||||
*/
|
||||
if (event instanceof DragEvent) {
|
||||
let data: DataTransfer | null = event.dataTransfer
|
||||
if (data && data.files.length && data.files.length > 0) {
|
||||
for (const file of data.files) {
|
||||
uploadFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 黏贴上传
|
||||
*/
|
||||
if (event instanceof ClipboardEvent) {
|
||||
if (!event.clipboardData) return
|
||||
if (event.clipboardData.items.length === 0) return
|
||||
for (let i = 0; i < event.clipboardData.items.length; i++) {
|
||||
const file: File | null = event.clipboardData.items[i].getAsFile()
|
||||
if (file == null) {
|
||||
return
|
||||
}
|
||||
uploadFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< html 事件监听 >----------------------------
|
||||
@ -401,7 +474,7 @@ const clickCurDoc = async (tree: DocTree) => {
|
||||
// 如果选中的是文章, 则查询文章详情, 用于在编辑器中显示以及注入
|
||||
if (doc.type == 3) {
|
||||
// 重复点击同一个, 不会多次查询
|
||||
if (curIsArticle() && curArticle.value!.id == doc.id) {
|
||||
if (isArticle(curArticle.value) && curArticle.value!.id == doc.id) {
|
||||
return
|
||||
}
|
||||
editorLoadingTimeout = setTimeout(() => (editorLoading.value = true), 100)
|
||||
@ -422,9 +495,7 @@ const clickCurDoc = async (tree: DocTree) => {
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (editorLoadingTimeout) {
|
||||
clearTimeout(editorLoadingTimeout)
|
||||
}
|
||||
if (editorLoadingTimeout) clearTimeout(editorLoadingTimeout)
|
||||
editorLoading.value = false
|
||||
articleChanged = false
|
||||
})
|
||||
@ -439,22 +510,23 @@ const clickCurDoc = async (tree: DocTree) => {
|
||||
* @param auto 是否为自动保存, 如果是自动保存, 则不弹出保存成功的提示框, 避免在非用户主动操作下弹框
|
||||
*/
|
||||
const saveCurArticleContent = async (auto: boolean = false) => {
|
||||
if (!curIsArticle()) {
|
||||
if (!isArticle(curArticle.value)) {
|
||||
return
|
||||
}
|
||||
const saveCallback = () => {
|
||||
if (!auto) {
|
||||
ElMessage.success({ message: '保存成功', duration: 1000, offset: 70, grouping: true })
|
||||
ElMessage.info({ message: '保存成功', duration: 1000, offset: 70, grouping: true })
|
||||
}
|
||||
}
|
||||
// 如果文档发生变动才保存
|
||||
if (!articleChanged) {
|
||||
console.info('%c文档内容无变化, 无需保存', 'background:#AD8CF2;color:#fff;')
|
||||
console.info('%c文档内容无变化, 无需保存', 'background:#AD8CF2;color:#fff;padding-top:2px')
|
||||
saveCallback()
|
||||
return
|
||||
}
|
||||
// 如果文档正在解析中, 则等待解析完成
|
||||
while (articleParseing) {
|
||||
console.info('%c检测到正在解析, 等待解析完成', 'background:#AD7736;color:#fff;padding-top:2px')
|
||||
await sleep(100)
|
||||
}
|
||||
articleChanged = false
|
||||
@ -463,7 +535,6 @@ const saveCurArticleContent = async (auto: boolean = false) => {
|
||||
name: curArticle.value!.name,
|
||||
markdown: cmw.getDocString(),
|
||||
html: PreviewRef.value.innerHTML,
|
||||
toc: JSON.stringify(articleToc.value),
|
||||
references: articleImg.value.concat(articleLink.value)
|
||||
}
|
||||
await articleUpdContentApi(data)
|
||||
@ -506,21 +577,6 @@ const distoryAutoSaveInterval = () => {
|
||||
const autoSave = () => {
|
||||
saveCurArticleContent(true)
|
||||
}
|
||||
/**
|
||||
* 判断当前选中的是否是文章
|
||||
*/
|
||||
const curIsArticle = (): boolean => {
|
||||
if (isNull(curArticle)) {
|
||||
return false
|
||||
}
|
||||
if (isNull(curArticle.value)) {
|
||||
return false
|
||||
}
|
||||
if (isNull(curArticle.value?.type) || curArticle.value?.type != 3) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
@ -528,42 +584,7 @@ const curIsArticle = (): boolean => {
|
||||
let cmw: CmWrapper // codemirror editor wrapper
|
||||
|
||||
/**
|
||||
* 文件上传回调
|
||||
* @param event DragEvent | ClipboardEvent
|
||||
*/
|
||||
const uploadFileCallback = async (event: DragEvent | ClipboardEvent) => {
|
||||
if (!curIsArticle()) return
|
||||
|
||||
/**
|
||||
* 拖拽上传
|
||||
*/
|
||||
if (event instanceof DragEvent) {
|
||||
let data: DataTransfer | null = event.dataTransfer
|
||||
if (data && data.files.length && data.files.length > 0) {
|
||||
for (const file of data.files) {
|
||||
uploadFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 黏贴上传
|
||||
*/
|
||||
if (event instanceof ClipboardEvent) {
|
||||
if (!event.clipboardData) return
|
||||
if (event.clipboardData.items.length === 0) return
|
||||
for (let i = 0; i < event.clipboardData.items.length; i++) {
|
||||
const file: File | null = event.clipboardData.items[i].getAsFile()
|
||||
if (file == null) {
|
||||
return
|
||||
}
|
||||
uploadFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化编辑器
|
||||
* 初始化编辑器, 创建编辑器封装器, 并在编辑器底部增加一个空白页
|
||||
*/
|
||||
const initEditor = (_doc?: string) => {
|
||||
cmw = new CmWrapper(
|
||||
@ -572,7 +593,7 @@ const initEditor = (_doc?: string) => {
|
||||
CmWrapper.newState(
|
||||
() => {
|
||||
articleParseing = true
|
||||
debounce(parse, 300)
|
||||
debounceParse(parse, 300)
|
||||
},
|
||||
saveCurArticleContent,
|
||||
uploadFileCallback
|
||||
@ -580,15 +601,7 @@ const initEditor = (_doc?: string) => {
|
||||
EditorRef.value
|
||||
)
|
||||
)
|
||||
// 创建元素
|
||||
let editorHeightHolder = document.createElement('div')
|
||||
editorHeightHolder.style.height = '65vh'
|
||||
editorHeightHolder.addEventListener('click', () => {
|
||||
let length = cmw.getDocLength()
|
||||
cmw.editor.focus()
|
||||
cmw.insert(length, length, '', length, length)
|
||||
})
|
||||
EditorRef.value.appendChild(editorHeightHolder)
|
||||
appendEditorHolder()
|
||||
}
|
||||
/**
|
||||
* 将 markdown 原文设置到编辑器中, 并且会重置编辑器状态
|
||||
@ -600,7 +613,8 @@ const setNewState = (md: string): void => {
|
||||
() => {
|
||||
articleChanged = true
|
||||
articleParseing = true
|
||||
debounce(parse, 300)
|
||||
allwaysBottom()
|
||||
debounceParse(parse, 300)
|
||||
},
|
||||
saveCurArticleContent,
|
||||
uploadFileCallback,
|
||||
@ -610,11 +624,43 @@ const setNewState = (md: string): void => {
|
||||
parse()
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器底部增加空白占位元素, 点击占位元素会时会聚焦在编辑器
|
||||
*/
|
||||
const appendEditorHolder = () => {
|
||||
// 创建元素
|
||||
let editorHeightHolder = document.createElement('div')
|
||||
editorHeightHolder.style.height = '65vh'
|
||||
editorHeightHolder.style.position = 'relative'
|
||||
editorHeightHolder.addEventListener('click', () => {
|
||||
let length = cmw.getDocLength()
|
||||
cmw.editor.focus()
|
||||
cmw.insert(length, length, '', length, length)
|
||||
})
|
||||
EditorRef.value.appendChild(editorHeightHolder)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器滚动条永远置底
|
||||
*/
|
||||
const allwaysBottom = async () => {
|
||||
const clientHeight = EditorRef.value.clientHeight
|
||||
const scrollTop = EditorRef.value.scrollTop
|
||||
const scrollHeight = EditorRef.value.scrollHeight
|
||||
let a = clientHeight + scrollTop
|
||||
if (a >= scrollHeight - 100) {
|
||||
scrollWrapper.toBottom()
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< marked/preview >-------------------------------
|
||||
const renderInterval = ref(0) // 解析用时
|
||||
const articleHtml = ref('') // 解析后的 html 内容
|
||||
const renderAsync = ref({
|
||||
need: 0,
|
||||
done: 0
|
||||
})
|
||||
let immediateParse = false // 是否立即渲染, 文档初次加载时立即渲染, 内容变更时防抖渲染
|
||||
/**
|
||||
* 自定义渲染
|
||||
@ -630,34 +676,23 @@ const renderer = {
|
||||
return renderCodespan(src)
|
||||
},
|
||||
code(code: string, language: string | undefined, _isEscaped: boolean): string {
|
||||
return renderCode(code, language, _isEscaped)
|
||||
return renderCode(code, language, _isEscaped, renderAsync.value)
|
||||
},
|
||||
heading(text: any, level: number): string {
|
||||
articleToc.value.push({ level: level, clazz: 'toc-' + level, index: articleToc.value.length, content: text })
|
||||
return renderHeading(text, level)
|
||||
heading(text: string, level: number, raw: string): string {
|
||||
return renderHeading(text, level, raw)
|
||||
},
|
||||
image(href: string | null, _title: string | null, text: string): string {
|
||||
articleImg.value.push({ targetId: '0', targetName: text, targetUrl: href as string, type: 10 })
|
||||
return renderImage(href, _title, text)
|
||||
},
|
||||
link(href: string | null, title: string | null, text: string): string {
|
||||
link(href: string, title: string | null | undefined, text: string): string {
|
||||
let { link, ref } = renderLink(href, title, text, ArticleTreeDocsRef.value.getDocTreeData())
|
||||
articleLink.value.push(ref)
|
||||
return link
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义解析
|
||||
*/
|
||||
const tokenizer = {
|
||||
codespan(src: string): any {
|
||||
return tokenizerCodespan(src)
|
||||
}
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
marked.use({ tokenizer: tokenizer, renderer: renderer })
|
||||
marked.use({ renderer: renderer })
|
||||
|
||||
/**
|
||||
* 解析 markdown 为 html, 并将 html 赋值给 articleHtml
|
||||
@ -667,35 +702,55 @@ const parse = () => {
|
||||
immediateParse = false
|
||||
let mdContent = cmw.getDocString()
|
||||
clearTocAndImg()
|
||||
marked.parse(mdContent, { async: true }).then((content: string) => {
|
||||
articleHtml.value = content
|
||||
renderInterval.value = Date.now() - begin
|
||||
articleParseing = false
|
||||
})
|
||||
renderAsync.value = {
|
||||
need: 0,
|
||||
done: 0
|
||||
}
|
||||
marked
|
||||
.parse(mdContent, { async: true })
|
||||
.then((content: string) => {
|
||||
articleHtml.value = content
|
||||
renderInterval.value = Date.now() - begin
|
||||
articleParseing = false
|
||||
})
|
||||
.then(() => {
|
||||
nextTick(() => {
|
||||
parseToc()
|
||||
}).then(() => {
|
||||
const clientHeight = EditorRef.value.clientHeight
|
||||
const scrollTop = EditorRef.value.scrollTop
|
||||
const scrollHeight = EditorRef.value.scrollHeight
|
||||
let a = clientHeight + scrollTop
|
||||
if (a >= scrollHeight - 150) {
|
||||
setTimeout(() => {
|
||||
PreviewRef.value.scrollTop = PreviewRef.value.scrollHeight
|
||||
}, 7)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖, 防止频繁渲染造成的卡顿
|
||||
*/
|
||||
let debounceTimeout: NodeJS.Timeout | undefined
|
||||
function debounce(fn: () => void, time = 500) {
|
||||
function debounceParse(parseFn: () => void, time = 500) {
|
||||
if (debounceTimeout != undefined) {
|
||||
clearTimeout(debounceTimeout)
|
||||
}
|
||||
if (immediateParse) {
|
||||
fn()
|
||||
parseFn()
|
||||
} else {
|
||||
debounceTimeout = setTimeout(fn, time)
|
||||
debounceTimeout = setTimeout(parseFn, time)
|
||||
}
|
||||
}
|
||||
|
||||
useResize(EditorRef, PreviewRef, ResizeDividerRef)
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< TOC >------------------------------------------
|
||||
const articleToc = ref<any[]>([])
|
||||
const articleImg = ref<ArticleReference[]>([]) // 文章对图片引用
|
||||
const articleLink = ref<ArticleReference[]>([]) // 文章对链接的引用
|
||||
const articleToc = shallowRef<Toc[]>([])
|
||||
const articleImg = shallowRef<ArticleReference[]>([]) // 文章对图片引用
|
||||
const articleLink = shallowRef<ArticleReference[]>([]) // 文章对链接的引用
|
||||
const TocRef = ref()
|
||||
const TocTitleRef = ref()
|
||||
/**
|
||||
@ -703,78 +758,42 @@ const TocTitleRef = ref()
|
||||
* @param level 标题级别
|
||||
* @param content 标题内容
|
||||
*/
|
||||
const toScroll = (level: number, content: string) => {
|
||||
let id = level + '-' + content
|
||||
const toScroll = (id: string) => {
|
||||
let elm: HTMLElement = document.getElementById(id) as HTMLElement
|
||||
;(elm.parentNode as Element).scrollTop = elm.offsetTop
|
||||
}
|
||||
// 清空当前目录内容
|
||||
const clearTocAndImg = () => {
|
||||
articleToc.value = []
|
||||
articleImg.value = []
|
||||
articleLink.value = []
|
||||
}
|
||||
|
||||
const parseToc = async () => {
|
||||
parseTocAsync(PreviewRef.value).then((tocs) => (articleToc.value = tocs))
|
||||
}
|
||||
|
||||
useDraggable(TocRef, TocTitleRef)
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 双屏滚动 >----------------------------------------
|
||||
let scrollWrapper: EPScroll
|
||||
const initScroll = () => {
|
||||
const initScroll = async () => {
|
||||
scrollWrapper = new EPScroll(EditorRef.value, PreviewRef.value, cmw)
|
||||
}
|
||||
|
||||
const scroll = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
|
||||
scrollWrapper.sycnScroll(event, source, lineno, colno, error)
|
||||
}
|
||||
|
||||
const scrollTopReset = () => {
|
||||
if (scrollWrapper) {
|
||||
scrollWrapper.scrollTopReset()
|
||||
}
|
||||
const scrollTopReset = () => scrollWrapper.scrollTopReset()
|
||||
const scrollTopLast = () => scrollWrapper.scrollTopLast()
|
||||
const addListenerScroll = () => EditorRef.value.addEventListener('scroll', scroll)
|
||||
const removeListenerScroll = () => EditorRef.value.removeEventListener('scroll', scroll)
|
||||
const scrollTop = () => scrollWrapper.toTop()
|
||||
const scrollBottom = () => scrollWrapper.toBottom()
|
||||
const handleSyncScroll = () => {
|
||||
editorOperator.value.sycnScroll = scrollWrapper.open()
|
||||
}
|
||||
|
||||
const scrollTopLast = () => {
|
||||
if (scrollWrapper) {
|
||||
scrollWrapper.scrollTopLast()
|
||||
}
|
||||
}
|
||||
|
||||
const addListenerScroll = () => {
|
||||
EditorRef.value.addEventListener('scroll', scroll)
|
||||
}
|
||||
|
||||
const removeListenerScroll = () => {
|
||||
EditorRef.value.removeEventListener('scroll', scroll)
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 双屏滚动2 >----------------------------------------
|
||||
// let CmEditorRef
|
||||
// let scrollWrapper: EPScroll
|
||||
// const initScroll = () => {
|
||||
// CmEditorRef = document.getElementsByClassName('cm-scroller')[0]
|
||||
// scrollWrapper = new EPScroll(CmEditorRef, PreviewRef.value, cmw)
|
||||
// }
|
||||
|
||||
// const scroll = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
|
||||
// scrollWrapper.sycnScroll(event, source, lineno, colno, error)
|
||||
// }
|
||||
|
||||
// const scrollTopLast = () => {
|
||||
// if (scrollWrapper) {
|
||||
// scrollWrapper.scrollTopLast()
|
||||
// }
|
||||
// }
|
||||
|
||||
// const addListenerScroll = () => {
|
||||
// CmEditorRef.addEventListener('scroll', scroll)
|
||||
// }
|
||||
|
||||
// const removeListenerScroll = () => {
|
||||
// CmEditorRef.removeEventListener('scroll', scroll)
|
||||
// }
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 编辑器右键 >----------------------------------------
|
||||
|
1053
blossom-editor/src/renderer/src/views/article/ArticleIndexNew.vue
Normal file
1053
blossom-editor/src/renderer/src/views/article/ArticleIndexNew.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,944 @@
|
||||
<template>
|
||||
<div class="index-article-root">
|
||||
<!-- folder menu -->
|
||||
<div class="doc-container" :style="{ width: docEditorStyle.docs }" v-show="docsExpand">
|
||||
<div class="doc-tree-menu-container" :style="tempTextareaStyle.docTree">
|
||||
<ArticleTreeDocs @click-doc="clickCurDoc" ref="ArticleTreeDocsRef"></ArticleTreeDocs>
|
||||
</div>
|
||||
|
||||
<div class="doc-temp-textarea">
|
||||
<bl-row just="space-between" height="28px" class="doc-temp-textarea-workbench">
|
||||
<bl-row><img src="@renderer/assets/imgs/note/cd.png" />临时内容(可从便签快速设置)</bl-row>
|
||||
<div class="iconbl bl-subtract-line" @click="tempTextareaExpand = !tempTextareaExpand"></div>
|
||||
</bl-row>
|
||||
<bl-row class="doc-temp-textarea-input" :style="tempTextareaStyle.tempTextarea">
|
||||
<el-input v-model="tempTextarea" type="textarea" resize="none" @input="tempInput"></el-input>
|
||||
</bl-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- editor -->
|
||||
<div class="editor-container" :style="{ width: docEditorStyle.editor }" v-loading="editorLoading" element-loading-text="正在读取文章内容...">
|
||||
<div class="editor-tools">
|
||||
<EditorTools
|
||||
@save="saveCurArticleContent()"
|
||||
@preview-full-screen="alt_3()"
|
||||
@editor-full-screen="alt_4()"
|
||||
@bold="cmw.commandBold()"
|
||||
@italic="cmw.commandItalic()"
|
||||
@strike="cmw.commandStrike()"
|
||||
@sub="cmw.commandSub()"
|
||||
@sup="cmw.commandSup()"
|
||||
@separator="cmw.commandSeparator()"
|
||||
@blockquote="cmw.commandQuote()"
|
||||
@blockquote-block="cmw.commandQuoteBlack()"
|
||||
@blockquote-green="cmw.commandQuoteGreen()"
|
||||
@blockquote-yellow="cmw.commandQuoteYellow()"
|
||||
@blockquote-red="cmw.commandQuoteRed()"
|
||||
@blockquote-blue="cmw.commandQuoteBlue()"
|
||||
@blockquote-purple="cmw.commandQuotePurple()"
|
||||
@code="cmw.commandCode()"
|
||||
@pre="cmw.commandPre()"
|
||||
@checkbox="cmw.commandCheckBox()"
|
||||
@unordered="cmw.commandUnordered()"
|
||||
@ordered="cmw.commandOrdered()"
|
||||
@table="cmw.commandTable()"
|
||||
@image="cmw.commandImg()"
|
||||
@link="cmw.commandLink()">
|
||||
</EditorTools>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器与预览 -->
|
||||
<div class="editor-preview" :style="editorStyle">
|
||||
<div v-if="!curArticle" class="ep-placeholder">
|
||||
<ArticleIndexPlaceholder></ArticleIndexPlaceholder>
|
||||
</div>
|
||||
<div class="operator" ref="EditorOperatorRef">
|
||||
<el-tooltip popper-class="is-small" effect="light" placement="top" transition="none" :show-after="500" :hide-after="0" :show-arrow="false">
|
||||
<template #content>
|
||||
当编辑超大文档时
|
||||
<bl-row>
|
||||
可关闭同步预览
|
||||
<div class="iconbl bl-eye-line" style="transform: rotate(90deg)"></div>
|
||||
与同步滚动<span class="iconbl bl-scroll"></span>提升性能
|
||||
</bl-row>
|
||||
</template>
|
||||
<div class="iconbl bl-admonish-line"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
:content="'同步滚动:' + (editorOperator.sycnScroll ? '开启' : '关闭')"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="right"
|
||||
transition="none"
|
||||
:show-after="500"
|
||||
:hide-after="0"
|
||||
:show-arrow="false">
|
||||
<div
|
||||
class="iconbl bl-scroll"
|
||||
:style="{ color: editorOperator.sycnScroll ? 'var(--el-color-primary-light-3)' : '' }"
|
||||
@click="handleSyncScroll"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="前往顶部"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="right"
|
||||
transition="none"
|
||||
:show-after="500"
|
||||
:hide-after="0"
|
||||
:show-arrow="false">
|
||||
<div class="iconbl bl-a-doubleonline-line" @click="scrollTop"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="前往底部"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="right"
|
||||
transition="none"
|
||||
:show-after="500"
|
||||
:hide-after="0"
|
||||
:show-arrow="false">
|
||||
<div class="iconbl bl-a-doubleunderline-line" @click="scrollBottom"></div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="gutter-holder" ref="GutterHolderRef"></div>
|
||||
<div class="editor-codemirror" ref="EditorRef" @click.right="handleEditorClickRight"></div>
|
||||
<div class="resize-divider" ref="ResizeDividerRef"></div>
|
||||
<div class="preview-marked bl-preview" ref="PreviewRef" v-html="articleHtml"></div>
|
||||
</div>
|
||||
|
||||
<!-- status -->
|
||||
<div class="editor-status">
|
||||
<EditorStatus :render-interval="renderInterval"></EditorStatus>
|
||||
</div>
|
||||
|
||||
<!-- toc -->
|
||||
<div :class="['bl-preview-toc-absolute', tocsExpand ? 'is-expand-open' : 'is-expand-close']" ref="TocRef">
|
||||
<div class="toc-title" ref="TocTitleRef">
|
||||
目录
|
||||
<span v-show="tocsExpand" style="font-size: 10px">({{ keymaps.hideToc }} 可隐藏)</span>
|
||||
</div>
|
||||
<div class="toc-content" v-show="tocsExpand">
|
||||
<div v-for="toc in articleToc" :key="toc.id" :class="[toc.clazz]" @click="toScroll(toc.id)" v-html="toc.content"></div>
|
||||
</div>
|
||||
<div class="img-title">
|
||||
引用图片
|
||||
<el-tooltip effect="light" placement="right" :hide-after="0">
|
||||
<template #content> 重复上传图片后<br />如果图片无变化可刷新缓存 </template>
|
||||
<span class="iconbl bl-refresh-line" @click="refreshCache"></span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="img-content">
|
||||
<div class="img-wrapper" v-for="image in articleImg" :key="image.targetUrl" @click="showPicInfo(image.targetUrl)">
|
||||
<img :src="picCacheWrapper(image.targetUrl)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PictureViewerInfo ref="PictureViewerInfoRef" @saved="refreshCache"></PictureViewerInfo>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="editorRightMenu.show"
|
||||
class="editor-right-menu"
|
||||
:style="{ left: editorRightMenu.clientX + 'px', top: editorRightMenu.clientY + 'px' }">
|
||||
<div class="menu-content">
|
||||
<div v-if="isElectron()" class="menu-item" @click="rightMenuCopy"><span class="iconbl bl-copy-line"></span>复制</div>
|
||||
<div v-if="isElectron()" class="menu-item" @click="rightMenuPaste"><span class="iconbl bl-a-texteditorpastetext-line"></span>黏贴</div>
|
||||
<div class="menu-item">
|
||||
<el-upload
|
||||
name="file"
|
||||
:action="serverStore.serverUrl + uploadFileApiUrl"
|
||||
:data="(f: UploadRawFile) => uploadDate(f, curArticle!.pid)"
|
||||
:headers="{ Authorization: 'Bearer ' + userStore.auth.token }"
|
||||
:show-file-list="false"
|
||||
:before-upload="beforeUpload"
|
||||
:on-success="onUploadSeccess"
|
||||
:on-error="onError">
|
||||
<bl-row><span class="iconbl bl-image--line"></span>插入图片</bl-row>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div class="menu-item" @click="upper"><span class="iconbl bl-daxie"></span>大写</div>
|
||||
<div class="menu-item" @click="lower"><span class="iconbl bl-xiaoxie"></span>小写</div>
|
||||
<div class="menu-item" @click="formatTable"><span class="iconbl bl-transcript-line"></span>格式化选中表格</div>
|
||||
<div class="menu-item" @click="openExtenal('https://katex.org/#demo')">
|
||||
<span class="iconbl bl-a-texteditorsuperscript-line"></span>Katex 在线校验
|
||||
</div>
|
||||
<div class="menu-item" @click="openExtenal('https://mermaid.live/edit')">
|
||||
<span class="iconbl bl-a-statisticalviewpiechart3-line"></span>Mermaid 在线校验
|
||||
</div>
|
||||
<div class="menu-item" @click="openExtenal('https://www.emojiall.com/zh-hans')">
|
||||
<span style="margin-right: 4px; padding: 2px 0">😉</span>Emoji网站
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="articleReferenceView.show" ref="ArticleViewRef" class="article-view-absolute bl-preview" :style="articleReferenceView.style">
|
||||
<div class="content-view bl-preview" v-html="articleReferenceView.html" :style="editorStyle"></div>
|
||||
<bl-row class="workbench" just="space-between">
|
||||
<div class="btns">
|
||||
<div @click="openArticleWindow(articleReferenceView.articleId)">新窗口打开</div>
|
||||
</div>
|
||||
<div class="infos">{{ articleReferenceView.name }}</div>
|
||||
</bl-row>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// vue
|
||||
import { ref, computed, provide, onMounted, onBeforeUnmount, onActivated, onDeactivated, defineAsyncComponent, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { UploadProps, UploadRawFile } from 'element-plus'
|
||||
import { useUserStore } from '@renderer/stores/user'
|
||||
import { useServerStore } from '@renderer/stores/server'
|
||||
import { useConfigStore } from '@renderer/stores/config'
|
||||
import { articleInfoApi, articleUpdContentApi, uploadFileApiUrl } from '@renderer/api/blossom'
|
||||
// utils
|
||||
import { Local } from '@renderer/assets/utils/storage'
|
||||
import { isBlank, isNull } from '@renderer/assets/utils/obj'
|
||||
import { sleep, isElectron } from '@renderer/assets/utils/util'
|
||||
import { openExtenal, writeText, readText, openNewArticleWindow } from '@renderer/assets/utils/electron'
|
||||
import { formartMarkdownTable } from '@renderer/assets/utils/format-table'
|
||||
// component
|
||||
import ArticleTreeDocs from './ArticleTreeDocs.vue'
|
||||
import ArticleIndexPlaceholder from './ArticleIndexPlaceholder.vue'
|
||||
import EditorTools from './EditorTools.vue'
|
||||
// ts
|
||||
import hotkeys from 'hotkeys-js'
|
||||
import Notify from '@renderer/scripts/notify'
|
||||
import { useDraggable } from '@renderer/scripts/draggable'
|
||||
import type { shortcutFunc } from '@renderer/scripts/shortcut-register'
|
||||
import { treeToInfo, provideKeyDocInfo, provideKeyCurArticleInfo, isArticle } from '@renderer/views/doc/doc'
|
||||
import { TempTextareaKey, ArticleReference, DocEditorStyle, parseTocAsync } from './scripts/article'
|
||||
import type { Toc } from './scripts/article'
|
||||
import { beforeUpload, onError, picCacheWrapper, picCacheRefresh, uploadForm, uploadDate } from '@renderer/views/picture/scripts/picture'
|
||||
import { useResize } from './scripts/editor-preview-resize'
|
||||
// codemirror
|
||||
import { CmWrapper } from './scripts/codemirror'
|
||||
// marked
|
||||
import marked, { renderBlockquote, renderCode, renderCodespan, renderHeading, renderImage, renderTable, renderLink } from './scripts/markedjs'
|
||||
import { EPScroll } from './scripts/editor-preview-scroll'
|
||||
import { useArticleHtmlEvent } from './scripts/article-html-event'
|
||||
import { shallowRef } from 'vue'
|
||||
import { keymaps } from './scripts/editor-tools'
|
||||
|
||||
//#region -- mounted
|
||||
|
||||
const PictureViewerInfo = defineAsyncComponent(() => import('@renderer/views/picture/PictureViewerInfo.vue'))
|
||||
// const EditorTools = defineAsyncComponent(() => import('./EditorTools.vue'))
|
||||
const EditorStatus = defineAsyncComponent(() => import('./EditorStatus.vue'))
|
||||
let isMounted = false
|
||||
|
||||
onMounted(() => {
|
||||
initEditor()
|
||||
initScroll()
|
||||
addListenerScroll()
|
||||
initAutoSaveInterval()
|
||||
if (!isMounted) {
|
||||
enterView()
|
||||
bindKeys()
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
unbindKeys()
|
||||
removeListenerEditorRightMenu()
|
||||
removeListenerScroll()
|
||||
distoryAutoSaveInterval()
|
||||
})
|
||||
onActivated(() => {
|
||||
if (isMounted) {
|
||||
enterView()
|
||||
bindKeys()
|
||||
}
|
||||
isMounted = true
|
||||
})
|
||||
onDeactivated(() => {
|
||||
exitView()
|
||||
unbindKeys()
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
const serverStore = useServerStore()
|
||||
const { editorStyle } = useConfigStore()
|
||||
|
||||
watch(
|
||||
() => userStore.userinfo.id,
|
||||
(_newId: string, _oldId: string) => {
|
||||
curDoc.value = undefined
|
||||
curArticle.value = undefined
|
||||
setNewState('')
|
||||
}
|
||||
)
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 公共参数和页面动态样式 >--------------------------------------
|
||||
const GutterHolderRef = ref() // editor gutter holder
|
||||
const EditorRef = ref() // editor
|
||||
const ResizeDividerRef = ref() // editor&preview resize dom
|
||||
const EditorOperatorRef = ref()
|
||||
const PreviewRef = ref() // html 预览
|
||||
const editorOperator = ref({
|
||||
syncParse: true,
|
||||
sycnScroll: true
|
||||
})
|
||||
/**
|
||||
* 文档列表的展开和收起
|
||||
*/
|
||||
const docsExpand = ref<boolean>(true)
|
||||
const tocsExpand = ref<boolean>(true)
|
||||
const docEditorStyle = computed<DocEditorStyle>(() => {
|
||||
if (!docsExpand.value) {
|
||||
return { docs: '0px', editor: '100%' }
|
||||
}
|
||||
return { docs: '250px', editor: 'calc(100% - 250px)' }
|
||||
})
|
||||
|
||||
/**
|
||||
* 编辑器和预览的展开收起
|
||||
*/
|
||||
let previewFullScreen = false // 是否全屏展开预览
|
||||
let editorFullScreen = false // 是否全屏展开编辑
|
||||
const changeEditorPreviewStyle = () => {
|
||||
if (previewFullScreen) {
|
||||
GutterHolderRef.value.style.width = '0px'
|
||||
EditorRef.value.style.width = '0px'
|
||||
PreviewRef.value.style.width = '100%'
|
||||
PreviewRef.value.style.padding = '10px 20px 0 30px'
|
||||
EditorOperatorRef.value.style.display = 'none'
|
||||
return
|
||||
}
|
||||
if (editorFullScreen) {
|
||||
GutterHolderRef.value.style.width = '50px'
|
||||
EditorRef.value.style.width = 'calc(100% - 6px)'
|
||||
PreviewRef.value.style.width = '0'
|
||||
PreviewRef.value.style.padding = '0'
|
||||
EditorOperatorRef.value.style.display = 'none'
|
||||
return
|
||||
}
|
||||
GutterHolderRef.value.style.width = '50px'
|
||||
EditorRef.value.style.width = '50%'
|
||||
PreviewRef.value.style.width = '50%'
|
||||
PreviewRef.value.style.padding = '10px 20px 0 30px'
|
||||
EditorOperatorRef.value.style.display = 'block'
|
||||
EditorOperatorRef.value.style.left = 'calc(50% - 0.5px)'
|
||||
}
|
||||
/**
|
||||
* 临时文本框
|
||||
*/
|
||||
const tempTextarea = ref('')
|
||||
const tempTextareaExpand = ref(true)
|
||||
const tempTextareaStyle = computed<any>(() => {
|
||||
if (tempTextareaExpand.value) {
|
||||
return {
|
||||
docTree: { height: 'calc(100% - 178px)' },
|
||||
tempTextarea: { height: '150px', padding: '10px' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
docTree: { height: 'calc(100% - 28px)' },
|
||||
tempTextarea: { height: '0', padding: '' }
|
||||
}
|
||||
})
|
||||
const initTempTextarea = () => {
|
||||
tempTextarea.value = Local.get('editor_temp_textarea_value')
|
||||
}
|
||||
const tempInput = (value: string) => {
|
||||
Local.set(TempTextareaKey, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入页面时, 保存文章
|
||||
*/
|
||||
const enterView = () => {
|
||||
autoSave()
|
||||
initTempTextarea()
|
||||
scrollTopLast()
|
||||
}
|
||||
/**
|
||||
* 退出页面时, 保存文章
|
||||
*/
|
||||
const exitView = () => {
|
||||
autoSave()
|
||||
}
|
||||
|
||||
useResize(EditorRef, PreviewRef, ResizeDividerRef, EditorOperatorRef)
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 图片管理 >--------------------------------------
|
||||
const PictureViewerInfoRef = ref()
|
||||
const showPicInfo = (url: string) => {
|
||||
PictureViewerInfoRef.value.showPicInfo(url)
|
||||
}
|
||||
const refreshCache = () => {
|
||||
picCacheRefresh()
|
||||
parse()
|
||||
}
|
||||
|
||||
/**
|
||||
* 右键菜单的上传回调
|
||||
* @param resp
|
||||
* @param file
|
||||
*/
|
||||
const onUploadSeccess: UploadProps['onSuccess'] = (resp, file) => {
|
||||
if (resp.code === '20000') {
|
||||
cmw.insertBlockCommand(`\n![${file.name}](${resp.data})\n`)
|
||||
} else {
|
||||
Notify.error(resp.msg, '上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽和黏贴上传
|
||||
* @param file 文件
|
||||
*/
|
||||
const uploadFile = (file: File) => {
|
||||
uploadForm(file, curArticle.value!.pid, (url: string) => {
|
||||
cmw.insertBlockCommand(`\n![${file.name}](${url})\n`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传回调
|
||||
* @param event DragEvent | ClipboardEvent
|
||||
*/
|
||||
const uploadFileCallback = async (event: DragEvent | ClipboardEvent) => {
|
||||
if (!isArticle(curArticle.value)) return
|
||||
|
||||
/**
|
||||
* 拖拽上传
|
||||
*/
|
||||
if (event instanceof DragEvent) {
|
||||
let data: DataTransfer | null = event.dataTransfer
|
||||
if (data && data.files.length && data.files.length > 0) {
|
||||
for (const file of data.files) {
|
||||
uploadFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 黏贴上传
|
||||
*/
|
||||
if (event instanceof ClipboardEvent) {
|
||||
if (!event.clipboardData) return
|
||||
if (event.clipboardData.items.length === 0) return
|
||||
for (let i = 0; i < event.clipboardData.items.length; i++) {
|
||||
const file: File | null = event.clipboardData.items[i].getAsFile()
|
||||
if (file == null) {
|
||||
return
|
||||
}
|
||||
uploadFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< html 事件监听 >----------------------------
|
||||
const ArticleViewRef = ref()
|
||||
const { articleReferenceView } = useArticleHtmlEvent(ArticleViewRef)
|
||||
|
||||
const openArticleWindow = (id: string) => {
|
||||
openNewArticleWindow('article_window_' + id, id)
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 文档列表与当前文章 >----------------------------
|
||||
const editorLoading = ref(false) // eidtor loading
|
||||
const ArticleTreeDocsRef = ref()
|
||||
const curDoc = ref<DocInfo>() // 当前选中的文档, 包含文件夹和文章, 如果选中是文件夹, 则不会重置编辑器中的文章
|
||||
const curArticle = ref<DocInfo>() // 当前选中的文章, 用于在编辑器中展示
|
||||
// 自定保存间隔, 5分钟不编辑则自动保存
|
||||
const authSaveMs = 5 * 60 * 1000
|
||||
// 非绑定数据
|
||||
// 文章是否在解析时, 为 true 则正在解析, 为 false 则解析完成
|
||||
let articleParseing = false
|
||||
// 编辑器内容是否有变更, 防止在没有变更时频繁保存导致请求接口和版本号的无意义变更, 如果为 true, 则文章允许保存, 为 false 时跳过保存
|
||||
let articleChanged = false
|
||||
// 上次保存时间
|
||||
let lastSaveTime: number = new Date().getTime()
|
||||
// 自动保存定时器
|
||||
let autoSaveInterval: NodeJS.Timeout
|
||||
// 文章加载延迟遮罩
|
||||
let editorLoadingTimeout: NodeJS.Timeout
|
||||
|
||||
provide(provideKeyDocInfo, curDoc)
|
||||
provide(provideKeyCurArticleInfo, curArticle)
|
||||
|
||||
/**
|
||||
* 点击 doc title 的回调, 用于选中某个文档
|
||||
* 选中分为两种
|
||||
* 1:选中的是文件夹
|
||||
* 2:选中的是文章, 则查询文章内容, 变
|
||||
*
|
||||
* @param tree
|
||||
*/
|
||||
const clickCurDoc = async (tree: DocTree) => {
|
||||
let doc: DocInfo = treeToInfo(tree)
|
||||
curDoc.value = doc
|
||||
// 如果选中的是文章, 则查询文章详情, 用于在编辑器中显示以及注入
|
||||
if (doc.type == 3) {
|
||||
// 重复点击同一个, 不会多次查询
|
||||
if (isArticle(curArticle.value) && curArticle.value!.id == doc.id) {
|
||||
return
|
||||
}
|
||||
editorLoadingTimeout = setTimeout(() => (editorLoading.value = true), 100)
|
||||
await saveCurArticleContent(true)
|
||||
clearTocAndImg()
|
||||
await articleInfoApi({ id: doc.id, showToc: false, showMarkdown: true, showHtml: false })
|
||||
.then((resp) => {
|
||||
if (isNull(resp.data)) {
|
||||
return
|
||||
}
|
||||
curArticle.value = resp.data
|
||||
// 初次加载时立即渲染
|
||||
immediateParse = true
|
||||
if (isBlank(resp.data.markdown)) {
|
||||
setNewState('')
|
||||
} else {
|
||||
setNewState(resp.data.markdown)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (editorLoadingTimeout) clearTimeout(editorLoadingTimeout)
|
||||
editorLoading.value = false
|
||||
articleChanged = false
|
||||
})
|
||||
nextTick(() => {
|
||||
scrollTopReset()
|
||||
})
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 保存文章的正文, 并更新编辑器状态栏中的版本, 字数, 修改时间等信息.
|
||||
*
|
||||
* @param auto 是否为自动保存, 如果是自动保存, 则不弹出保存成功的提示框, 避免在非用户主动操作下弹框
|
||||
*/
|
||||
const saveCurArticleContent = async (auto: boolean = false) => {
|
||||
if (!isArticle(curArticle.value)) {
|
||||
return
|
||||
}
|
||||
const saveCallback = () => {
|
||||
if (!auto) {
|
||||
ElMessage.success({ message: '保存成功', duration: 1000, offset: 70, grouping: true })
|
||||
}
|
||||
}
|
||||
// 如果文档发生变动才保存
|
||||
if (!articleChanged) {
|
||||
console.info('%c文档内容无变化, 无需保存', 'background:#AD8CF2;color:#fff;')
|
||||
saveCallback()
|
||||
return
|
||||
}
|
||||
// 如果文档正在解析中, 则等待解析完成
|
||||
while (articleParseing) {
|
||||
console.log('检测到正在解析, 等待解析完成')
|
||||
await sleep(100)
|
||||
}
|
||||
articleChanged = false
|
||||
let data = {
|
||||
id: curArticle.value!.id,
|
||||
name: curArticle.value!.name,
|
||||
markdown: cmw.getDocString(),
|
||||
html: PreviewRef.value.innerHTML,
|
||||
references: articleImg.value.concat(articleLink.value)
|
||||
}
|
||||
await articleUpdContentApi(data)
|
||||
.then((resp) => {
|
||||
lastSaveTime = new Date().getTime()
|
||||
curArticle.value!.words = resp.data.words as number
|
||||
curArticle.value!.updTime = resp.data.updTime as string
|
||||
if (curArticle.value!.version != undefined) {
|
||||
curArticle.value!.version = curArticle.value!.version + 1
|
||||
} else {
|
||||
curArticle.value!.version = 1
|
||||
}
|
||||
saveCallback()
|
||||
})
|
||||
.catch(() => {
|
||||
articleChanged = true
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 初始化自动保存定时器
|
||||
* 如果 authSaveMs 时间没有保存, 则自动保存.
|
||||
*/
|
||||
const initAutoSaveInterval = () => {
|
||||
autoSaveInterval = setInterval(() => {
|
||||
let current = new Date().getTime()
|
||||
if (current - lastSaveTime > authSaveMs) {
|
||||
autoSave()
|
||||
}
|
||||
}, 30 * 1000)
|
||||
}
|
||||
/**
|
||||
* 销毁自动保存定时器
|
||||
*/
|
||||
const distoryAutoSaveInterval = () => {
|
||||
clearInterval(autoSaveInterval)
|
||||
}
|
||||
/**
|
||||
* 自动保存, 该种方式不会有保存成功的提示
|
||||
*/
|
||||
const autoSave = () => {
|
||||
saveCurArticleContent(true)
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< codemirror/editor >----------------------------
|
||||
let cmw: CmWrapper // codemirror editor wrapper
|
||||
|
||||
/**
|
||||
* 初始化编辑器, 创建编辑器封装器, 并在编辑器底部增加一个空白页
|
||||
*/
|
||||
const initEditor = (_doc?: string) => {
|
||||
cmw = new CmWrapper(
|
||||
CmWrapper.newEditor(
|
||||
// 创建 state
|
||||
CmWrapper.newState(
|
||||
() => {
|
||||
articleParseing = true
|
||||
debounceParse(parse, 300)
|
||||
},
|
||||
saveCurArticleContent,
|
||||
uploadFileCallback
|
||||
),
|
||||
EditorRef.value
|
||||
)
|
||||
)
|
||||
appendEditorHolder()
|
||||
}
|
||||
/**
|
||||
* 将 markdown 原文设置到编辑器中, 并且会重置编辑器状态
|
||||
* @param md markdown
|
||||
*/
|
||||
const setNewState = (md: string): void => {
|
||||
cmw.setState(
|
||||
CmWrapper.newState(
|
||||
() => {
|
||||
articleChanged = true
|
||||
articleParseing = true
|
||||
allwaysBottom()
|
||||
debounceParse(parse, 300)
|
||||
},
|
||||
saveCurArticleContent,
|
||||
uploadFileCallback,
|
||||
md
|
||||
)
|
||||
)
|
||||
parse()
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器底部增加空白占位元素, 点击占位元素会时会聚焦在编辑器
|
||||
*/
|
||||
const appendEditorHolder = () => {
|
||||
// 创建元素
|
||||
let editorHeightHolder = document.createElement('div')
|
||||
editorHeightHolder.style.height = '65vh'
|
||||
editorHeightHolder.style.position = 'relative'
|
||||
editorHeightHolder.addEventListener('click', () => {
|
||||
let length = cmw.getDocLength()
|
||||
cmw.editor.focus()
|
||||
cmw.insert(length, length, '', length, length)
|
||||
})
|
||||
EditorRef.value.appendChild(editorHeightHolder)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器滚动条永远置底
|
||||
*/
|
||||
const allwaysBottom = async () => {
|
||||
const clientHeight = EditorRef.value.clientHeight
|
||||
const scrollTop = EditorRef.value.scrollTop
|
||||
const scrollHeight = EditorRef.value.scrollHeight
|
||||
let a = clientHeight + scrollTop
|
||||
if (a >= scrollHeight - 100) {
|
||||
scrollWrapper.toBottom()
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< marked/preview >-------------------------------
|
||||
const renderInterval = ref(0) // 解析用时
|
||||
const articleHtml = ref('') // 解析后的 html 内容
|
||||
const renderAsync = ref({
|
||||
need: 0,
|
||||
done: 0
|
||||
})
|
||||
let immediateParse = false // 是否立即渲染, 文档初次加载时立即渲染, 内容变更时防抖渲染
|
||||
/**
|
||||
* 自定义渲染
|
||||
*/
|
||||
const renderer = {
|
||||
table(header: string, body: string): string {
|
||||
return renderTable(header, body)
|
||||
},
|
||||
blockquote(quote: string): string {
|
||||
return renderBlockquote(quote)
|
||||
},
|
||||
codespan(src: string): string {
|
||||
return renderCodespan(src)
|
||||
},
|
||||
code(code: string, language: string | undefined, _isEscaped: boolean): string {
|
||||
return renderCode(code, language, _isEscaped, renderAsync.value)
|
||||
},
|
||||
heading(text: string, level: number, raw: string): string {
|
||||
return renderHeading(text, level, raw)
|
||||
},
|
||||
image(href: string | null, _title: string | null, text: string): string {
|
||||
articleImg.value.push({ targetId: '0', targetName: text, targetUrl: href as string, type: 10 })
|
||||
return renderImage(href, _title, text)
|
||||
},
|
||||
link(href: string, title: string | null | undefined, text: string): string {
|
||||
let { link, ref } = renderLink(href, title, text, ArticleTreeDocsRef.value.getDocTreeData())
|
||||
articleLink.value.push(ref)
|
||||
return link
|
||||
}
|
||||
}
|
||||
|
||||
marked.use({ renderer: renderer })
|
||||
|
||||
/**
|
||||
* 解析 markdown 为 html, 并将 html 赋值给 articleHtml
|
||||
*/
|
||||
const parse = () => {
|
||||
const begin = Date.now()
|
||||
immediateParse = false
|
||||
let mdContent = cmw.getDocString()
|
||||
clearTocAndImg()
|
||||
renderAsync.value = {
|
||||
need: 0,
|
||||
done: 0
|
||||
}
|
||||
marked
|
||||
.parse(mdContent, { async: true })
|
||||
.then((content: string) => {
|
||||
articleHtml.value = content
|
||||
renderInterval.value = Date.now() - begin
|
||||
articleParseing = false
|
||||
})
|
||||
.then(() => {
|
||||
nextTick(() => {
|
||||
parseToc()
|
||||
}).then(() => {
|
||||
const clientHeight = EditorRef.value.clientHeight
|
||||
const scrollTop = EditorRef.value.scrollTop
|
||||
const scrollHeight = EditorRef.value.scrollHeight
|
||||
let a = clientHeight + scrollTop
|
||||
if (a >= scrollHeight - 150) {
|
||||
setTimeout(() => {
|
||||
PreviewRef.value.scrollTop = PreviewRef.value.scrollHeight
|
||||
}, 7)
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(renderAsync.value)
|
||||
}, 500)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖, 防止频繁渲染造成的卡顿
|
||||
*/
|
||||
let debounceTimeout: NodeJS.Timeout | undefined
|
||||
function debounceParse(parseFn: () => void, time = 500) {
|
||||
if (debounceTimeout != undefined) {
|
||||
clearTimeout(debounceTimeout)
|
||||
}
|
||||
if (immediateParse) {
|
||||
parseFn()
|
||||
} else {
|
||||
debounceTimeout = setTimeout(parseFn, time)
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< TOC >------------------------------------------
|
||||
const articleToc = shallowRef<Toc[]>([])
|
||||
const articleImg = shallowRef<ArticleReference[]>([]) // 文章对图片引用
|
||||
const articleLink = shallowRef<ArticleReference[]>([]) // 文章对链接的引用
|
||||
const TocRef = ref()
|
||||
const TocTitleRef = ref()
|
||||
/**
|
||||
* 跳转至指定ID位置,ID为 标题级别-标题内容
|
||||
* @param level 标题级别
|
||||
* @param content 标题内容
|
||||
*/
|
||||
const toScroll = (id: string) => {
|
||||
let elm: HTMLElement = document.getElementById(id) as HTMLElement
|
||||
;(elm.parentNode as Element).scrollTop = elm.offsetTop
|
||||
}
|
||||
// 清空当前目录内容
|
||||
const clearTocAndImg = () => {
|
||||
articleImg.value = []
|
||||
articleLink.value = []
|
||||
}
|
||||
|
||||
const parseToc = async () => {
|
||||
parseTocAsync(PreviewRef.value).then((tocs) => (articleToc.value = tocs))
|
||||
}
|
||||
|
||||
useDraggable(TocRef, TocTitleRef)
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 双屏滚动 >----------------------------------------
|
||||
let scrollWrapper: EPScroll
|
||||
const initScroll = async () => {
|
||||
scrollWrapper = new EPScroll(EditorRef.value, PreviewRef.value, cmw)
|
||||
}
|
||||
const scroll = (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) => {
|
||||
scrollWrapper.sycnScroll(event, source, lineno, colno, error)
|
||||
}
|
||||
const scrollTopReset = () => scrollWrapper.scrollTopReset()
|
||||
const scrollTopLast = () => scrollWrapper.scrollTopLast()
|
||||
const addListenerScroll = () => EditorRef.value.addEventListener('scroll', scroll)
|
||||
const removeListenerScroll = () => EditorRef.value.removeEventListener('scroll', scroll)
|
||||
const scrollTop = () => scrollWrapper.toTop()
|
||||
const scrollBottom = () => scrollWrapper.toBottom()
|
||||
const handleSyncScroll = () => {
|
||||
editorOperator.value.sycnScroll = scrollWrapper.open()
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 编辑器右键 >----------------------------------------
|
||||
const editorRightMenu = ref<RightMenu>({ show: false, clientX: 0, clientY: 0 })
|
||||
const rightMenuHeight = isElectron() ? 270 : 220
|
||||
|
||||
const handleEditorClickRight = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
editorRightMenu.value = { show: false, clientX: 0, clientY: 0 }
|
||||
let y = event.clientY
|
||||
if (document.body.clientHeight - event.clientY < rightMenuHeight) {
|
||||
y = event.clientY - rightMenuHeight
|
||||
}
|
||||
editorRightMenu.value = { show: true, clientX: event.clientX, clientY: y }
|
||||
setTimeout(() => {
|
||||
document.body.addEventListener('click', closeEditorRightMenu)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const closeEditorRightMenu = () => {
|
||||
removeListenerEditorRightMenu()
|
||||
editorRightMenu.value.show = false
|
||||
}
|
||||
|
||||
const removeListenerEditorRightMenu = () => {
|
||||
document.body.removeEventListener('click', closeEditorRightMenu)
|
||||
}
|
||||
|
||||
/** 复制当前选中内容 */
|
||||
const rightMenuCopy = () => {
|
||||
writeText(cmw.getSelectionRangesText())
|
||||
}
|
||||
/** 右键黏贴功能 */
|
||||
const rightMenuPaste = () => {
|
||||
cmw.insertBlockCommand(readText())
|
||||
}
|
||||
|
||||
/** 转大写功能 */
|
||||
const upper = () => {
|
||||
cmw.toUpper()
|
||||
}
|
||||
|
||||
/** 转小写功能 */
|
||||
const lower = () => {
|
||||
cmw.toLower()
|
||||
}
|
||||
|
||||
/**
|
||||
* 右键格式化表格功能
|
||||
*/
|
||||
const formatTable = () => {
|
||||
let ranges = cmw.getSlelctionRangesArr()
|
||||
if (ranges.length < 1) {
|
||||
Notify.error('未选中内容')
|
||||
return
|
||||
}
|
||||
if (ranges.length > 1) {
|
||||
Notify.error('选中内容过多')
|
||||
return
|
||||
}
|
||||
let text = cmw.sliceDoc(ranges[0].from, ranges[0].to)
|
||||
if (isBlank(text)) {
|
||||
return
|
||||
}
|
||||
cmw.insertBlockCommand(formartMarkdownTable(text))
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< 快捷键注册 >-------------------------------------
|
||||
const alt_1: shortcutFunc = (): void => {
|
||||
docsExpand.value = !docsExpand.value
|
||||
}
|
||||
const alt_2: shortcutFunc = (): void => {
|
||||
tocsExpand.value = !tocsExpand.value
|
||||
}
|
||||
// 全屏预览
|
||||
const alt_3: shortcutFunc = (): void => {
|
||||
previewFullScreen = !previewFullScreen
|
||||
if (previewFullScreen) {
|
||||
editorFullScreen = false
|
||||
}
|
||||
changeEditorPreviewStyle()
|
||||
}
|
||||
// 全屏编辑
|
||||
const alt_4: shortcutFunc = (): void => {
|
||||
editorFullScreen = !editorFullScreen
|
||||
if (previewFullScreen) {
|
||||
previewFullScreen = false
|
||||
}
|
||||
changeEditorPreviewStyle()
|
||||
}
|
||||
|
||||
hotkeys.filter = function (_event) {
|
||||
return true
|
||||
}
|
||||
|
||||
const bindKeys = () => {
|
||||
hotkeys('alt+1, command+1', () => {
|
||||
alt_1()
|
||||
return false
|
||||
})
|
||||
hotkeys('alt+2, command+2', () => {
|
||||
alt_2()
|
||||
return false
|
||||
})
|
||||
hotkeys('alt+3, command+3', () => {
|
||||
alt_3()
|
||||
return false
|
||||
})
|
||||
hotkeys('alt+4, command+4', () => {
|
||||
alt_4()
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
const unbindKeys = () => {
|
||||
hotkeys.unbind('alt+1, command+1')
|
||||
hotkeys.unbind('alt+2, command+2')
|
||||
hotkeys.unbind('alt+3, command+3')
|
||||
hotkeys.unbind('alt+4, command+4')
|
||||
}
|
||||
|
||||
//#endregion
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import './styles/article-index.scss';
|
||||
@import './styles/article-view-absolute.scss';
|
||||
@import './styles/editor-right-menu.scss';
|
||||
@import './styles/bl-preview-toc.scss';
|
||||
@import './styles/article-backtop.scss';
|
||||
@import '@renderer/assets/styles/bl-loading-spinner.scss';
|
||||
</style>
|
@ -57,7 +57,7 @@ import { keymaps } from './scripts/editor-tools'
|
||||
@include flex(column, center, center);
|
||||
|
||||
.iconbl {
|
||||
@include themeColor(#dbdbdb, #323232);
|
||||
@include themeColor(#dbdbdb, #444444);
|
||||
font-size: 25px;
|
||||
padding: 3px 2px;
|
||||
transition: 0.3s;
|
||||
@ -65,11 +65,11 @@ import { keymaps } from './scripts/editor-tools'
|
||||
}
|
||||
|
||||
.desc-line {
|
||||
@include themeBorder(1px, #dcdcdc, #323232);
|
||||
@include themeBorder(1px, #dcdcdc, #444444);
|
||||
position: absolute;
|
||||
|
||||
.desc-text {
|
||||
@include themeColor(#dbdbdb, #323232);
|
||||
@include themeColor(#dbdbdb, #444444);
|
||||
font-size: 13px;
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
@ -153,13 +153,13 @@ import { keymaps } from './scripts/editor-tools'
|
||||
width: 210px;
|
||||
font-size: 13px;
|
||||
.label {
|
||||
@include themeColor(#dbdbdb, #323232);
|
||||
@include themeColor(#dbdbdb, #444444);
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.key {
|
||||
@include themeBg(#f1f1f1, #323232);
|
||||
@include themeBg(#f1f1f1, #444444);
|
||||
@include themeColor(#c6c6c6, #151515);
|
||||
border-radius: 4px;
|
||||
padding: 2px 5px;
|
||||
|
@ -19,7 +19,7 @@
|
||||
<div class="name">{{ recycle.name }}</div>
|
||||
<div class="size">{{ recycle.delTime }}|{{ remainingDays(recycle.delTime) }}天后删除</div>
|
||||
<el-button class="restore-btn" @click="restore(recycle.id)"><span class="iconbl bl-a-cloudupload-line"></span></el-button>
|
||||
<el-button class="download-btn" @click=""><span class="iconbl bl-folder-download-line"></span></el-button>
|
||||
<el-button class="download-btn" @click="download(recycle.id)"><span class="iconbl bl-folder-download-line"></span></el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -29,7 +29,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useUserStore } from '@renderer/stores/user'
|
||||
import { articleRecycleListApi, articleRecycleRestoreApi } from '@renderer/api/blossom'
|
||||
import { articleRecycleListApi, articleRecycleRestoreApi, articleRecycleDownloadApi } from '@renderer/api/blossom'
|
||||
import { isNull } from '@renderer/assets/utils/obj'
|
||||
import { isEmpty } from 'lodash'
|
||||
import dayjs from 'dayjs'
|
||||
@ -68,6 +68,26 @@ const restore = (id: string) => {
|
||||
getBackupList()
|
||||
})
|
||||
}
|
||||
|
||||
const download = (id: string) => {
|
||||
articleRecycleDownloadApi({ id: id }).then((resp) => {
|
||||
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()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
@ -68,8 +68,9 @@ let stat = ref({
|
||||
outside: 0
|
||||
})
|
||||
|
||||
let inside = { itemStyle: {}, label: {} }
|
||||
let outside = { itemStyle: {}, label: {} }
|
||||
let inside: any = { itemStyle: {}, label: {} }
|
||||
let insideUnknown: any = { itemStyle: {}, label: {} }
|
||||
let outside: any = { itemStyle: {}, label: {} }
|
||||
const changeStyle = () => {
|
||||
let primaryColor = getPrimaryColor()
|
||||
// 节点数量统计
|
||||
@ -85,6 +86,17 @@ const changeStyle = () => {
|
||||
textBorderWidth: 2
|
||||
}
|
||||
}
|
||||
insideUnknown = {
|
||||
itemStyle: {
|
||||
color: isDark.value ? '#7B0000' : '#EB6969'
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
color: isDark.value ? '#BABABA' : '#030303',
|
||||
textBorderColor: isDark.value ? '#7B0000' : '#EB6969',
|
||||
textBorderWidth: 1
|
||||
}
|
||||
}
|
||||
outside = {
|
||||
itemStyle: {
|
||||
color: isDark.value ? '#7B5E00' : '#FDC81A87'
|
||||
@ -116,6 +128,9 @@ const getArticleRefList = (onlyInner: boolean) => {
|
||||
node.itemStyle = inside.itemStyle
|
||||
node.label = inside.label
|
||||
stat.value.inside += 1
|
||||
} else if (node.artType === 12) {
|
||||
node.itemStyle = insideUnknown.itemStyle
|
||||
node.label = insideUnknown.label
|
||||
} else if (node.artType == 21) {
|
||||
node.itemStyle = outside.itemStyle
|
||||
node.label = outside.label
|
||||
@ -139,6 +154,9 @@ const ascending = 1
|
||||
const getLinkCount = (name: string, links: any[]): number => {
|
||||
let count: number = 20
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
if (count >= 100) {
|
||||
break
|
||||
}
|
||||
let link = links[i]
|
||||
if (link.source == name) {
|
||||
count += ascending
|
||||
@ -172,10 +190,19 @@ const renderChart = () => {
|
||||
userStore.userinfo.userParams.WEB_ARTICLE_URL + params.data.artId
|
||||
}</a></div>`
|
||||
}
|
||||
return `<div class="chart-graph-article-ref-tooltip">
|
||||
console.log(params.data)
|
||||
let type = ''
|
||||
if (params.data.artType === 11) {
|
||||
type = `<div>类型: 内部文章</div>`
|
||||
} else if (params.data.artType === 12) {
|
||||
type = `<div style="color:${insideUnknown.itemStyle.color}">类型: 未知文章, 可能是文章ID错误或已被删除</div>`
|
||||
} else if (params.data.artType === 21) {
|
||||
type = `<div>类型: 外网文章</div>`
|
||||
}
|
||||
return `<div class="chart-graph-article-ref-tooltip" style="border:1px solid ${params.data.itemStyle.color}">
|
||||
<div class="title">${params.data.name}</div>
|
||||
<div class="content">
|
||||
<div>类型: ${params.data.inner ? '内部文章' : '外网文章'}</div>
|
||||
${type}
|
||||
${url}
|
||||
</div>
|
||||
</div>`
|
||||
@ -250,7 +277,7 @@ const renderChart = () => {
|
||||
// edgeLabel: { show: false },
|
||||
},
|
||||
blur: {
|
||||
itemStyle: { opacity: 0.1 },
|
||||
// itemStyle: { opacity: 0.1 },
|
||||
lineStyle: { opacity: 0.1 },
|
||||
label: { show: false },
|
||||
edgeLabel: { show: false }
|
||||
@ -377,10 +404,10 @@ onUnmounted(() => {
|
||||
white-space: normal;
|
||||
background-color: var(--bl-html-color);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--el-color-primary-light-5);
|
||||
color: var(--bl-text-color);
|
||||
|
||||
.title {
|
||||
@include font(15px, 300);
|
||||
@include font(15px, 500);
|
||||
border-bottom: 1px solid var(--el-color-primary-light-5);
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
|
@ -453,6 +453,7 @@ const unbindKeys = () => {
|
||||
padding: 0 3px 2px 3px;
|
||||
border-top: 1px solid var(--el-border-color);
|
||||
overflow-y: scroll;
|
||||
color: var(--bl-text-color-light);
|
||||
}
|
||||
|
||||
.workbench {
|
||||
@ -487,6 +488,10 @@ const unbindKeys = () => {
|
||||
border-left: 1px solid var(--el-color-primary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown {
|
||||
color: var(--bl-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
|
@ -118,7 +118,7 @@
|
||||
<div v-if="curDoc.ty !== 3" @click="handleShowArticleImportDialog()"><span class="iconbl bl-file-upload-line"></span>导入文章</div>
|
||||
|
||||
<!-- 更多二级菜单 -->
|
||||
<div @mouseenter="handleHoverRightMenuLevel2($event, 2)">
|
||||
<div @mouseenter="handleHoverRightMenuLevel2($event, 2)" data-bl-prevet="true">
|
||||
<span class="iconbl bl-a-rightsmallline-line"></span>
|
||||
<span class="iconbl bl-apps-line"></span>更多
|
||||
<div class="menu-content-level2" :style="rMenuLevel2">
|
||||
@ -147,7 +147,7 @@
|
||||
<div v-if="curDoc.ty === 3" @click="createUrl('tempVisit', true)"><span class="iconbl bl-visit"></span>浏览器临时访问</div>
|
||||
|
||||
<!-- 导出及二级菜单 -->
|
||||
<div v-if="curDoc.ty === 3" @mouseenter="handleHoverRightMenuLevel2($event, 4)">
|
||||
<div v-if="curDoc.ty === 3" @mouseenter="handleHoverRightMenuLevel2($event, 4)" data-bl-prevet="true">
|
||||
<span class="iconbl bl-a-rightsmallline-line"></span>
|
||||
<span class="iconbl bl-file-download-line"></span>导出文章
|
||||
<div class="menu-content-level2" :style="rMenuLevel2">
|
||||
@ -157,7 +157,7 @@
|
||||
<div @click="articleBackup('HTML')"><span class="iconbl bl-HTML"></span>导出为本地 HTML</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="curDoc.ty === 3" @mouseenter="handleHoverRightMenuLevel2($event, 2)">
|
||||
<div v-if="curDoc.ty === 3" @mouseenter="handleHoverRightMenuLevel2($event, 2)" data-bl-prevet="true">
|
||||
<span class="iconbl bl-a-rightsmallline-line"></span>
|
||||
<span class="iconbl bl-a-linkspread-line"></span>复制链接
|
||||
<div class="menu-content-level2" :style="rMenuLevel2">
|
||||
@ -310,6 +310,9 @@ const getRouteQueryParams = () => {
|
||||
docTreeActiveArticleId.value = articleId as string
|
||||
let treeParam: any = { ty: 3, i: articleId }
|
||||
clickCurDoc(treeParam)
|
||||
nextTick(() => {
|
||||
docTreeActiveArticleId.value = articleId as string
|
||||
})
|
||||
}
|
||||
}
|
||||
/**
|
||||
@ -422,7 +425,15 @@ const handleClickRightMenu = (doc: DocTree, event: MouseEvent) => {
|
||||
})
|
||||
}
|
||||
|
||||
const closeTreeDocsMenuShow = () => {
|
||||
const closeTreeDocsMenuShow = (event: MouseEvent) => {
|
||||
if (event.target) {
|
||||
let isPrevent = (event.target as HTMLElement).getAttribute('data-bl-prevet')
|
||||
if (isPrevent === 'true') {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
document.body.removeEventListener('click', closeTreeDocsMenuShow)
|
||||
rMenu.value.show = false
|
||||
}
|
||||
@ -573,11 +584,14 @@ const syncDoc = () => {
|
||||
const delDoc = () => {
|
||||
let type = curDoc.value.ty === 3 ? '文章' : '文件夹'
|
||||
ElMessageBox.confirm(
|
||||
`是否确定删除${type}: <span style="color:#C02B2B;text-decoration: underline;">${curDoc.value.n}</span>?删除后的文章可在回收站中查看。`,
|
||||
`<strong>注意:</strong><br/>
|
||||
1. 公开访问记录将永久删除。<br/>
|
||||
2. 双链引用将永久删除,还原后续重新编辑才可再次生成。<br/>
|
||||
是否继续删除${type}: <span style="color:#C02B2B;text-decoration: underline;">${curDoc.value.n}</span>?`,
|
||||
{
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '我再想想',
|
||||
type: 'info',
|
||||
// type: 'warning',
|
||||
draggable: true,
|
||||
dangerouslyUseHTMLString: true
|
||||
}
|
||||
@ -790,7 +804,6 @@ const openArticle = (article: DocTree) => {
|
||||
})
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const clickCurDoc = (tree: DocTree) => {
|
||||
emits('clickDoc', tree)
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="doc-workbench-root">
|
||||
<bl-col class="workbench-name" just="flex-start" align="flex-end" height="46px" v-show="curDoc !== undefined">
|
||||
<span>《{{ curDoc?.name }}》</span>
|
||||
<span style="font-size: 9px; padding-right: 5px">{{ curDoc?.id }}</span>
|
||||
<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">
|
||||
<Transition name="wbpage-one">
|
||||
@ -126,7 +126,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, inject, onDeactivated } from 'vue'
|
||||
import { ArrowUp, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { provideKeyDocInfo, SortLevelColor } from '@renderer/views/doc/doc'
|
||||
import { provideKeyCurArticleInfo, SortLevelColor } from '@renderer/views/doc/doc'
|
||||
import { openNewArticleReferenceWindow } from '@renderer/assets/utils/electron'
|
||||
import { useConfigStore } from '@renderer/stores/config'
|
||||
import ArticleInfo from './ArticleInfo.vue'
|
||||
@ -174,7 +174,7 @@ const toWorkbenchPage = (page: number) => {
|
||||
//#endregion
|
||||
|
||||
//#region --------------------------------------------------< 查询 >--------------------------------------------------
|
||||
const curDoc = inject(provideKeyDocInfo)
|
||||
const curArticle = inject(provideKeyCurArticleInfo)
|
||||
|
||||
const onlyOpen = ref<boolean>(false) // 只显示公开
|
||||
const onlySubject = ref<boolean>(false) // 只显示专题
|
||||
|
@ -4,60 +4,62 @@
|
||||
<div class="bl-preview-toc-block">
|
||||
<div class="toc-subtitle">《{{ article?.name }}》</div>
|
||||
<div class="toc-subtitle">
|
||||
<span class="iconbl bl-pen-line"></span> {{ article?.words }} 字 |
|
||||
<span class="iconbl bl-read-line"></span> {{ article?.uv }} |
|
||||
<span class="iconbl bl-pen-line"></span> {{ article?.words }} 字 | <span class="iconbl bl-read-line"></span> {{ article?.uv }} |
|
||||
<span class="iconbl bl-like-line"></span> {{ article?.likes }}
|
||||
</div>
|
||||
<div class="toc-subtitle">
|
||||
<span class="iconbl bl-a-clock3-line"></span> 公开 {{ article?.openTime }}
|
||||
</div>
|
||||
<div class="toc-subtitle">
|
||||
<span class="iconbl bl-a-clock3-line"></span> 修改 {{ article?.updTime }}
|
||||
</div>
|
||||
<div class="toc-subtitle"><span class="iconbl bl-a-clock3-line"></span> 公开 {{ article?.openTime }}</div>
|
||||
<div class="toc-subtitle"><span class="iconbl bl-a-clock3-line"></span> 修改 {{ article?.updTime }}</div>
|
||||
<div class="toc-title">目录</div>
|
||||
<div class="toc-content">
|
||||
<div v-for="toc in tocList" :key="toc.index" :class="[toc.clazz]" @click="toScroll(toc.level, toc.content)">
|
||||
<div v-for="toc in tocs" :key="toc.id" :class="[toc.clazz]" @click="toScroll(toc.id)">
|
||||
{{ toc.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview bl-preview" :style="editorStyle" v-html="article?.html"></div>
|
||||
<div class="preview bl-preview" :style="editorStyle" v-html="article?.html" ref="WindowPreviewRef"></div>
|
||||
<el-backtop target=".preview" :right="50" :bottom="50">
|
||||
<div class="iconbl bl-send-line backtop"></div>
|
||||
<div class="iconbl bl-a-doubleonline-line backtop"></div>
|
||||
</el-backtop>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from "pinia"
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { articleInfoApi } from '@renderer/api/blossom'
|
||||
import { useConfigStore } from '@renderer/stores/config'
|
||||
|
||||
import { parseTocAsync } from './scripts/article'
|
||||
import type { Toc } from './scripts/article'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const { editorStyle } = storeToRefs(configStore)
|
||||
|
||||
const route = useRoute();
|
||||
const route = useRoute()
|
||||
const article = ref<DocInfo>()
|
||||
const tocList = ref<any>([])
|
||||
const tocs = ref<Toc[]>([])
|
||||
const WindowPreviewRef = ref()
|
||||
|
||||
/**
|
||||
* 跳转至指定ID位置,ID为 标题级别-标题内容
|
||||
* @param level 标题级别
|
||||
* @param content 标题内容
|
||||
*/
|
||||
const toScroll = (level: number, content: string) => {
|
||||
let id = level + '-' + content
|
||||
const toScroll = (id: string) => {
|
||||
let elm: HTMLElement = document.getElementById(id) as HTMLElement
|
||||
(elm.parentNode as Element).scrollTop = elm.offsetTop
|
||||
;(elm.parentNode as Element).scrollTop = elm.offsetTop - 40
|
||||
}
|
||||
|
||||
const initPreview = (articleId: string) => {
|
||||
articleInfoApi({ id: articleId, showToc: true, showMarkdown: false, showHtml: true }).then(resp => {
|
||||
articleInfoApi({ id: articleId, showToc: false, showMarkdown: false, showHtml: true }).then((resp) => {
|
||||
article.value = resp.data
|
||||
tocList.value = JSON.parse(resp.data.toc)
|
||||
nextTick(() => initToc())
|
||||
})
|
||||
}
|
||||
|
||||
const initToc = () => {
|
||||
parseTocAsync(WindowPreviewRef.value).then((toc) => {
|
||||
tocs.value = toc
|
||||
})
|
||||
}
|
||||
|
||||
@ -65,7 +67,7 @@ onMounted(() => {
|
||||
initPreview(route.query.articleId as string)
|
||||
})
|
||||
</script>
|
||||
<style scoped lang=scss>
|
||||
<style scoped lang="scss">
|
||||
@import './styles/bl-preview-toc.scss';
|
||||
@import './styles/article-backtop.scss';
|
||||
|
||||
@ -78,6 +80,7 @@ onMounted(() => {
|
||||
font-size: 15px;
|
||||
padding: 30px;
|
||||
overflow-y: overlay;
|
||||
overflow-x: hidden;
|
||||
line-height: 23px;
|
||||
|
||||
:deep(*) {
|
||||
@ -88,10 +91,7 @@ onMounted(() => {
|
||||
: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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@ -3,51 +3,240 @@
|
||||
<div class="tomato-bell" ref="TomatoBellRef"></div>
|
||||
<div class="tools-container">
|
||||
<!-- 文章的操作 -->
|
||||
<div class="iconbl bl-a-texteditorsave-line" @click="emits('save')"></div>
|
||||
<div class="iconbl bl-eye-line" @click="emits('previewFullScreen')"></div>
|
||||
<div class="iconbl bl-expansion-line" @click="emits('editorFullScreen')"></div>
|
||||
<el-tooltip
|
||||
content="保存"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-a-texteditorsave-line" @click="emits('save')"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="开启/关闭全屏预览"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-eye-line" @click="emits('previewFullScreen')"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="开启/关闭全屏编辑"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-expansion-line" @click="emits('editorFullScreen')"></div>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- -->
|
||||
<div class="divider"></div>
|
||||
<div class="iconbl bl-bold" @click="emits('bold')"></div>
|
||||
<div class="iconbl bl-italic" @click="emits('italic')"></div>
|
||||
<div class="iconbl bl-strikethrough" @click="emits('strike')"></div>
|
||||
<div class="iconbl bl-a-texteditorsuperscript-line" @click="emits('sup')"></div>
|
||||
<div class="iconbl bl-a-texteditorsubscript-line" @click="emits('sub')"></div>
|
||||
<div class="iconbl bl-separator" @click="emits('separator')"></div>
|
||||
<el-tooltip
|
||||
content="加粗"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-bold" @click="emits('bold')"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="斜体"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-italic" @click="emits('italic')"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="删除线"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-strikethrough" @click="emits('strike')"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="上标"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-a-texteditorsuperscript-line" @click="emits('sup')"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="下标"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-a-texteditorsubscript-line" @click="emits('sub')"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="删除线"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-separator" @click="emits('separator')"></div>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- -->
|
||||
<div class="divider"></div>
|
||||
<el-dropdown>
|
||||
<div class="iconbl bl-a-rightsmallline-line"></div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="emits('blockquote')">⚪ Normal</el-dropdown-item>
|
||||
<el-dropdown-item @click="emits('blockquoteBlock')">⚫ Black</el-dropdown-item>
|
||||
<el-dropdown-item @click="emits('blockquoteGreen')">🟢 Green</el-dropdown-item>
|
||||
<el-dropdown-item @click="emits('blockquoteYellow')">🟡 Yellow</el-dropdown-item>
|
||||
<el-dropdown-item @click="emits('blockquoteRed')">🔴 Red</el-dropdown-item>
|
||||
<el-dropdown-item @click="emits('blockquoteBlue')">🔵 Blue</el-dropdown-item>
|
||||
<el-dropdown-item @click="emits('blockquotePurple')">🟣 Purple</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<!-- </div> -->
|
||||
<div class="iconbl bl-single-quotes-r" @click="emits('code')"></div>
|
||||
<div class="iconbl bl-double-quotes-r" @click="emits('pre')"></div>
|
||||
<div class="iconbl bl-a-underbox-line" @click="emits('checkbox')"></div>
|
||||
<div class="iconbl bl-list-unordered" @click="emits('unordered')"></div>
|
||||
<div class="iconbl bl-list-ordered" @click="emits('ordered')"></div>
|
||||
<el-tooltip
|
||||
content="引用"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<el-dropdown>
|
||||
<div class="iconbl bl-a-rightsmallline-line"></div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="emits('blockquote')">⚪ Normal</el-dropdown-item>
|
||||
<el-dropdown-item @click="emits('blockquoteBlock')">⚫ Black</el-dropdown-item>
|
||||
<el-dropdown-item @click="emits('blockquoteGreen')">🟢 Green</el-dropdown-item>
|
||||
<el-dropdown-item @click="emits('blockquoteYellow')">🟡 Yellow</el-dropdown-item>
|
||||
<el-dropdown-item @click="emits('blockquoteRed')">🔴 Red</el-dropdown-item>
|
||||
<el-dropdown-item @click="emits('blockquoteBlue')">🔵 Blue</el-dropdown-item>
|
||||
<el-dropdown-item @click="emits('blockquotePurple')">🟣 Purple</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="行内代码块"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-single-quotes-r" @click="emits('code')"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="多行代码块"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-double-quotes-r" @click="emits('pre')"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="单选框"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-a-underbox-line" @click="emits('checkbox')"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="无序列表"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-list-unordered" @click="emits('unordered')"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="有序列表"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-list-ordered" @click="emits('ordered')"></div>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 表格及其他 -->
|
||||
<div class="divider"></div>
|
||||
<div class="iconbl bl-table-" @click="emits('table')"></div>
|
||||
<div class="iconbl bl-image--line" @click="emits('image')"></div>
|
||||
<div class="iconbl bl-link-m" @click="emits('link')"></div>
|
||||
<el-tooltip
|
||||
content="表格"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-table-" @click="emits('table')"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="图片链接"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-image--line" @click="emits('image')"></div>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
content="链接"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-link-m" @click="emits('link')"></div>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 其他工具 -->
|
||||
<div class="divider"></div>
|
||||
<div class="iconbl bl-jianpan-xianxing" @click="handleShowHotKeyDialog"></div>
|
||||
<el-tooltip
|
||||
content="快捷键说明"
|
||||
popper-class="is-small"
|
||||
effect="light"
|
||||
placement="top"
|
||||
transition="none"
|
||||
:hide-after="0"
|
||||
:show-arrow="false"
|
||||
:offset="10">
|
||||
<div class="iconbl bl-jianpan-xianxing" @click="handleShowHotKeyDialog"></div>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 番茄 -->
|
||||
<el-popover placement="bottom" :width="220" trigger="click" popper-style="padding:0;">
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { isNull } from '@renderer/assets/utils/obj'
|
||||
|
||||
/**
|
||||
* 临时内容的 localStorage key
|
||||
*/
|
||||
@ -41,7 +43,57 @@ export interface ArticleReference {
|
||||
*
|
||||
* 10 : picture
|
||||
* 11 : inner article
|
||||
* 12 : unknown inner article
|
||||
* 21 : public article
|
||||
*/
|
||||
type: 10 | 11 | 21
|
||||
type: 10 | 11 | 12 | 21
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录结构
|
||||
*/
|
||||
export interface Toc {
|
||||
content: string
|
||||
clazz: string
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析文章中的标题, 返回目录对象集合
|
||||
*
|
||||
* @param ele
|
||||
* @returns
|
||||
*/
|
||||
export const parseTocAsync = async (ele: HTMLElement): Promise<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
|
||||
}
|
@ -53,7 +53,10 @@ export const cwTheme: any = {
|
||||
backgroundColor: 'var(--bl-editor-gutters-bg-color)',
|
||||
borderColor: 'var(--bl-editor-gutters-border-color)',
|
||||
color: 'var(--bl-editor-gutters-color)',
|
||||
fontSize: '12px'
|
||||
fontSize: '12px',
|
||||
width: '50px',
|
||||
minWidth: '50px',
|
||||
maxWidth: '50px'
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--bl-editor-gutters-bg-color)',
|
||||
@ -364,7 +367,7 @@ export class CmWrapper {
|
||||
return editor.state.doc.toString()
|
||||
}
|
||||
/**
|
||||
* 获取文档长度
|
||||
* 获取文档的最大长度
|
||||
* @param editor
|
||||
* @returns 长度
|
||||
*/
|
||||
@ -442,6 +445,9 @@ export class CmWrapper {
|
||||
getDocLength = (): number => {
|
||||
return CmWrapper.getDocLength(this._editor)
|
||||
}
|
||||
getTotalLine = () => {
|
||||
return this.editor.state.doc.lines
|
||||
}
|
||||
getSelectionRangesText = (): string => {
|
||||
return CmWrapper.getSelectionRangesText(this._editor)
|
||||
}
|
||||
|
@ -10,9 +10,9 @@ import type { Ref } from 'vue'
|
||||
export const useResize = (
|
||||
editorRef: Ref<HTMLElement | undefined>,
|
||||
previewRef: Ref<HTMLElement | undefined>,
|
||||
resizeDividerRef: Ref<HTMLElement | undefined>
|
||||
resizeDividerRef: Ref<HTMLElement | undefined>,
|
||||
operatorRef: Ref<HTMLElement | undefined>
|
||||
) => {
|
||||
|
||||
const onMousedown = (_e: MouseEvent) => {
|
||||
const targetRect = editorRef.value!.getBoundingClientRect()
|
||||
// editor 距离应用左侧的距离
|
||||
@ -24,6 +24,7 @@ export const useResize = (
|
||||
const onMousemove = (e: MouseEvent) => {
|
||||
const x = Math.max(0, e.clientX - targetLeft)
|
||||
editorRef.value!.style.width = `${x}px`
|
||||
operatorRef.value!.style.left = `${x + 1}px`
|
||||
previewRef.value!.style.width = `calc(100% - ${x}px - 3px)`
|
||||
}
|
||||
|
||||
@ -59,4 +60,4 @@ export const useResize = (
|
||||
onBeforeUnmount(() => {
|
||||
offResize()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -27,6 +27,10 @@ export class EPScroll {
|
||||
* 保存编辑器滚动条的最后位置
|
||||
*/
|
||||
private _scrollTop: number = 0
|
||||
/**
|
||||
* 是否启用同步刷新
|
||||
*/
|
||||
private _open: boolean = true
|
||||
|
||||
constructor(editor: HTMLElement, previre: HTMLElement, cmw: CmWrapper | undefined) {
|
||||
this._editor = editor
|
||||
@ -35,26 +39,63 @@ export class EPScroll {
|
||||
this._scrollTop = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回滚动条位置
|
||||
*/
|
||||
public get scrollTop() {
|
||||
return this._scrollTop
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置滚动条
|
||||
*/
|
||||
public scrollTopReset() {
|
||||
this._scrollTop = 0
|
||||
this._editor.scrollTo({ top: 0 })
|
||||
this.toTop()
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到最近一次
|
||||
*/
|
||||
public scrollTopLast() {
|
||||
this._editor.scrollTo({ top: this._scrollTop })
|
||||
}
|
||||
|
||||
public sycnScroll(_event: Event | string, _source?: string, _lineno?: number, _colno?: number, _error?: Error): any {
|
||||
/**
|
||||
* 编辑器滚动条置顶
|
||||
*/
|
||||
public toTop() {
|
||||
this._editor.scrollTo({ top: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器滚动条置底
|
||||
*/
|
||||
public toBottom() {
|
||||
this._editor.scrollTo({ top: 999999999 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换同步滚动开启/关闭
|
||||
* @returns 是否同步滚动
|
||||
*/
|
||||
public open(): boolean {
|
||||
this._open = !this._open
|
||||
return this._open
|
||||
}
|
||||
|
||||
public sycnScroll(_event: Event | string, _source?: string, _lineno?: number, _colno?: number, _error?: Error): void {
|
||||
if (this._editor == undefined) {
|
||||
return
|
||||
}
|
||||
// console.log(this._editor?.scrollHeight, this._editor?.clientHeight, this._editor?.scrollTop)
|
||||
|
||||
this._scrollTop = this._editor.scrollTop
|
||||
|
||||
// 如果不开启同步滚动, 则返回
|
||||
if (!this._open) {
|
||||
return
|
||||
}
|
||||
// 如果在头部附近
|
||||
if (this._editor.scrollTop < 5) {
|
||||
this._preview.scrollTo({ top: 0 })
|
||||
@ -84,7 +125,7 @@ export class EPScroll {
|
||||
|
||||
// 将不可见的的 html 转换为 dom 对象, 是一个从 <html> 标签开始的 dom 对象
|
||||
const invisibleDomAll = new DOMParser().parseFromString(invisibleHtml, 'text/html')
|
||||
|
||||
|
||||
// body 下的内容才是由 markdown 转换而来的, 不可见内容转换的 dom 集合
|
||||
const editorDoms = invisibleDomAll.body.querySelectorAll(matchHtmlTags)
|
||||
// 预览页面的 dom 集合
|
||||
|
@ -7,7 +7,7 @@ import markedKatex from 'marked-katex-extension'
|
||||
import { markedHighlight } from 'marked-highlight'
|
||||
import hljs from 'highlight.js'
|
||||
// katex
|
||||
import katex from 'katex'
|
||||
import katex, { KatexOptions } from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
// mermaid
|
||||
import mermaid from 'mermaid'
|
||||
@ -33,7 +33,7 @@ const markmapOptions = deriveOptions({
|
||||
duration: 0, // 展开缩起动画
|
||||
maxWidth: 160, // 每个节点最大宽度
|
||||
zoom: true, // 缩放
|
||||
pan: false // 拖动
|
||||
pan: true // 拖动
|
||||
})
|
||||
|
||||
/**
|
||||
@ -54,7 +54,7 @@ export const singleDollar = /^\$+([^\$\n]+?)\$+/
|
||||
export const doubleDollar = /(?<=\$\$).*?(?=\$\$)/
|
||||
export const doubleWell = /(?<=\#\#).*?(?=\#\#)/
|
||||
|
||||
const katexOptions = {
|
||||
const katexOptions: KatexOptions = {
|
||||
throwOnError: false,
|
||||
displayMode: true,
|
||||
// 生成 katex-mathml 时会出现错误, mathml 绝对定位没有定到 katex-display 元素, 而是找到上级导致页面出现错误
|
||||
@ -86,32 +86,32 @@ let hljsConfig = {
|
||||
marked.use(markedHighlight(hljsConfig))
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< tokenizer >--------------------------------------
|
||||
export const tokenizerCodespan = (src: string): any => {
|
||||
const match = src.match(singleDollar)
|
||||
if (match) {
|
||||
let result = {
|
||||
type: 'codespan',
|
||||
raw: match[0],
|
||||
text: match[0]
|
||||
}
|
||||
return result
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region ----------------------------------------< renderer >--------------------------------------
|
||||
|
||||
const domParser = new DOMParser()
|
||||
/**
|
||||
* 标题解析为 TOC 集合, 增加锚点跳转
|
||||
* @param text 标题内容
|
||||
* @param level 标题级别
|
||||
* @param raw 原内容
|
||||
*/
|
||||
export const renderHeading = (text: any, level: number) => {
|
||||
const realLevel = level
|
||||
return `<h${realLevel} id="${realLevel}-${text}">${text}</h${realLevel}>`
|
||||
export const renderHeading = (text: string, level: number, raw: string) => {
|
||||
let id: string = randomInt(1000000, 9999999).toString()
|
||||
try {
|
||||
if (raw.indexOf('<') > -1 && raw.indexOf('>') > -1) {
|
||||
let dom = domParser.parseFromString(raw, 'text/html')
|
||||
if (dom) {
|
||||
id += dom.body.innerText
|
||||
} else {
|
||||
id += text
|
||||
}
|
||||
} else {
|
||||
id += text
|
||||
}
|
||||
} catch {
|
||||
id += text
|
||||
}
|
||||
|
||||
return `<h${level} id="${id}">${text}</h${level}>`
|
||||
}
|
||||
|
||||
/**
|
||||
@ -171,21 +171,24 @@ export const renderBlockquote = (quote: string) => {
|
||||
|
||||
/**
|
||||
* 自定义代码块内容解析:
|
||||
* 1. bilibili
|
||||
* 格式为: ```bilibili${grammar}bvid${grammar}w100${grammar}h100
|
||||
* 官方使用文档: https://player.bilibili.com/
|
||||
*
|
||||
* 1. mermaid (async)
|
||||
* 2. katex
|
||||
* 3. mermaid
|
||||
* 4. markmap
|
||||
* 3. markmap (async)
|
||||
* 4. bilibili (iframe), 文档: https://player.bilibili.com/
|
||||
*
|
||||
* @param code 解析后的 HTML 代码
|
||||
* @param language 语言
|
||||
* @param isEscaped
|
||||
*/
|
||||
export const renderCode = (code: string, language: string | undefined, _isEscaped: boolean) => {
|
||||
export const renderCode = (code: string, language: string | undefined, _isEscaped: boolean, asyncStat: { need: number; done: number }) => {
|
||||
if (language == undefined) language = 'text'
|
||||
|
||||
/** ==========================================================================================
|
||||
* 渲染 mermaid
|
||||
* ```mermaid${grammar}h300
|
||||
* ========================================================================================== */
|
||||
if (language.startsWith('mermaid') && isNotBlank(code)) {
|
||||
asyncStat.need++
|
||||
const eleid = 'mermaid-' + Date.now() + '-' + randomInt(1, 10000)
|
||||
const escape = escape2Html(code) as string
|
||||
|
||||
@ -206,10 +209,20 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
|
||||
.then((syntax) => {
|
||||
let canSyntax: boolean | void = syntax
|
||||
if (canSyntax) {
|
||||
mermaid.render(eleid + '-svg', escape).then((resp) => {
|
||||
mermaid.render(eleid + '-svg', escape).then(async (resp) => {
|
||||
const { svg } = resp
|
||||
let element = document.getElementById(eleid)
|
||||
element!.innerHTML = svg
|
||||
let retry = 0
|
||||
while (!element || element == null) {
|
||||
if (retry > 30) break
|
||||
await sleep(5)
|
||||
element = document.querySelector(`#${eleid}`)
|
||||
retry++
|
||||
}
|
||||
if (element) {
|
||||
element.innerHTML = svg
|
||||
}
|
||||
asyncStat.done++
|
||||
})
|
||||
}
|
||||
})
|
||||
@ -222,10 +235,14 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
|
||||
</p>`
|
||||
let element = document.getElementById(eleid)
|
||||
if (element) element!.innerHTML = html
|
||||
asyncStat.done++
|
||||
})
|
||||
return `<p class="mermaid-container" style="height:${height}" id="${eleid}">${eleid}</p>`
|
||||
return `<p class="mermaid-container" style="height:${height}" id="${eleid}"></p>`
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 katex
|
||||
*/
|
||||
if (language === 'katex') {
|
||||
try {
|
||||
return katex.renderToString(escape2Html(code), { throwOnError: true, displayMode: true, output: 'html' })
|
||||
@ -238,7 +255,12 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
|
||||
}
|
||||
}
|
||||
|
||||
/** ==========================================================================================
|
||||
* 渲染 markmap
|
||||
* ```markmap${grammar}h300
|
||||
* ========================================================================================== */
|
||||
if (language.startsWith('markmap')) {
|
||||
asyncStat.need++
|
||||
let height = '300px'
|
||||
let tags: string[] = language.split(grammar)
|
||||
if (tags.length >= 2) {
|
||||
@ -254,26 +276,40 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
|
||||
const eleid = 'markmap-' + Date.now() + '-' + randomInt(1, 10000)
|
||||
const escape = escape2Html(code) as string
|
||||
const { root } = transformer.transform(escape)
|
||||
new Promise<SVGElement>(async (resolve, reject) => {
|
||||
let svgEl: SVGElement | null = document.querySelector(`#${eleid}`)
|
||||
|
||||
// let svg: SVGElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
// svg.id = eleid
|
||||
// svg.setAttributeNS(null, 'width', '100%')
|
||||
// svg.setAttributeNS(null, 'height', '500px')
|
||||
// svg.setAttributeNS(null, 'viewBox', '0 0 100 500')
|
||||
// svg.classList.add('markmap')
|
||||
// svg.style.width = '500px'
|
||||
// svg.style.height = height
|
||||
// let markmap = Markmap.create(svg, markmapOptions, root)
|
||||
// console.log(svg.outerHTML)
|
||||
// return `<p class="markmap-container">${svg.outerHTML} </p>`
|
||||
|
||||
new Promise<SVGElement>(async (_resolve, _reject) => {
|
||||
let svg: SVGElement | null = document.querySelector(`#${eleid}`)
|
||||
let retry = 0
|
||||
while (!svgEl || svgEl == null) {
|
||||
if (retry > 10) break
|
||||
await sleep(10)
|
||||
svgEl = document.querySelector(`#${eleid}`)
|
||||
while (!svg || svg == null) {
|
||||
if (retry > 30) break
|
||||
await sleep(5)
|
||||
svg = document.querySelector(`#${eleid}`)
|
||||
retry++
|
||||
}
|
||||
if (svgEl) {
|
||||
resolve(svgEl)
|
||||
} else {
|
||||
reject()
|
||||
if (svg) {
|
||||
Markmap.create(svg, markmapOptions, root)
|
||||
}
|
||||
}).then((svgEl: SVGElement) => {
|
||||
Markmap.create(svgEl, markmapOptions, root)
|
||||
asyncStat.done++
|
||||
})
|
||||
return `<p><svg id=${eleid} xmlns="http://www.w3.org/2000/svg" style="width:100%;height:${height}"></svg></p>`
|
||||
return `<p class="markmap-container"><svg id=${eleid} xmlns="http://www.w3.org/2000/svg" style="width:100%;height:${height}"></svg></p>`
|
||||
}
|
||||
|
||||
/** ==========================================================================================
|
||||
* 渲染 bilibili
|
||||
* ```bilibili${grammar}bvid${grammar}w100${grammar}h100
|
||||
* ========================================================================================== */
|
||||
if (language.startsWith('bilibili')) {
|
||||
let bvid = ''
|
||||
let width = '100%'
|
||||
@ -295,7 +331,7 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
|
||||
if (tag.startsWith('h')) {
|
||||
height = tags[i].substring(1)
|
||||
if (!height.endsWith('%')) {
|
||||
width += 'px'
|
||||
height += 'px'
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -309,9 +345,10 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
|
||||
}
|
||||
|
||||
return `<iframe width="${width}" height="${height}" style="margin: 10px 0"
|
||||
scrolling="no" border="0" frameborder="no" framespacing="0"
|
||||
scrolling="no" border="0" frameborder="no" framespacing="0" loading="lazy"
|
||||
src="https://player.bilibili.com/player.html?bvid=${bvid}&page=1&autoplay=0" ></iframe>`
|
||||
}
|
||||
|
||||
const id = 'pre-' + Date.now() + '-' + randomInt(1, 1000000)
|
||||
const lines: string[] = code.split(/\n|\r\n?|\n\n+/g)
|
||||
let result = '<ol>'
|
||||
@ -328,25 +365,10 @@ export const renderCode = (code: string, language: string | undefined, _isEscape
|
||||
|
||||
/**
|
||||
* 单行代码块的解析拓展
|
||||
* 1. katex `$内部写表达式$`
|
||||
* @param src
|
||||
* @returns
|
||||
*/
|
||||
export const renderCodespan = (src: string) => {
|
||||
let arr = src.match(singleDollar)
|
||||
if (arr != null && arr.length > 0) {
|
||||
try {
|
||||
return katex.renderToString(arr[1], {
|
||||
throwOnError: true,
|
||||
output: 'html'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return `<div class='bl-preview-analysis-fail-inline'>
|
||||
Katex 语法解析失败! 你可以尝试前往<a href='https://katex.org/#demo' target='_blank'> Katex 官网</a> 来校验你的公式。
|
||||
</div>`
|
||||
}
|
||||
}
|
||||
return `<code>${src}</code>`
|
||||
}
|
||||
|
||||
@ -397,7 +419,12 @@ export const renderImage = (href: string | null, title: string | null, text: str
|
||||
* ref: 双链内容
|
||||
* }
|
||||
*/
|
||||
export const renderLink = (href: string | null, title: string | null, text: string, docTrees: DocTree[]) => {
|
||||
export const renderLink = (
|
||||
href: string,
|
||||
title: string | null | undefined,
|
||||
text: string,
|
||||
docTrees: DocTree[]
|
||||
): { link: string; ref: ArticleReference } => {
|
||||
let link: string
|
||||
let ref: ArticleReference = { targetId: '0', targetName: text, targetUrl: href as string, type: 21 }
|
||||
if (isBlank(title)) {
|
||||
@ -413,14 +440,18 @@ export const renderLink = (href: string | null, title: string | null, text: stri
|
||||
}
|
||||
|
||||
// 从文章列表中获取文章, 如果找到则认为是内部引用, 否则即使是内部引用格式, 也认为是个外部文章.
|
||||
// 内部引用不会使用 Markdown 中的链接名, 而是用内部文章名
|
||||
let article = getDocById(articleId.toString(), docTrees)
|
||||
if (article != undefined) {
|
||||
ref.targetId = article.i
|
||||
ref.targetName = article.n
|
||||
ref.type = 11
|
||||
} else {
|
||||
ref.targetId = articleId.toString()
|
||||
ref.targetName = '未知文章-' + articleId.toString()
|
||||
ref.type = 12
|
||||
}
|
||||
|
||||
// class="inner-link bl-tip bl-tip-bottom" data-tip="双链引用: 《${text}》"
|
||||
link = `<a target="_blank" href=${href} class="inner-link"
|
||||
onclick="onHtmlEventDispatch(this,'',event,'showArticleReferenceView','${ref.targetId}')">${text}</a>`
|
||||
} else {
|
||||
@ -460,21 +491,14 @@ const simpleRenderer = {
|
||||
}
|
||||
let lineNumbers = result + '</ol>'
|
||||
return `<pre><code class="hljs language-${language}"></code>${lineNumbers}<div class="pre-copy">${language}</div></pre>`
|
||||
// return `<pre><code class="hljs language-${language}">${code}</code></pre>`
|
||||
},
|
||||
codespan(src: string): string {
|
||||
return renderCodespan(src)
|
||||
}
|
||||
}
|
||||
|
||||
const tokenizer = {
|
||||
codespan(src: string): any {
|
||||
return tokenizerCodespan(src)
|
||||
}
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
simpleMarked.use({ tokenizer: tokenizer, renderer: simpleRenderer })
|
||||
simpleMarked.use({ renderer: simpleRenderer })
|
||||
|
||||
//#endregion
|
||||
|
||||
|
@ -1,16 +1,25 @@
|
||||
.el-backtop {
|
||||
@include box(37px, 37px);
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.backtop {
|
||||
@include box(100%, 100%);
|
||||
@include themeShadow(0 0 5px 1px #cecece, 0 0 5px 1px #000000);
|
||||
@include themeColor(#c2c2c2, #717171);
|
||||
@include themeShadow(0 0 3px 1px #0000001e, 0 0 3px 1px #000000);
|
||||
color: var(--bl-text-color-light);
|
||||
background-color: var(--bl-html-color);
|
||||
text-align: center;
|
||||
line-height: 40px;
|
||||
line-height: 38px;
|
||||
border-radius: 8px;
|
||||
font-size: 25px;
|
||||
transform: rotate(-90deg);
|
||||
transition: color 0.3s;
|
||||
font-size: 20px;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.backbottom {
|
||||
@extend .backtop;
|
||||
}
|
||||
|
@ -85,7 +85,7 @@
|
||||
|
||||
.gutter-holder {
|
||||
height: 100%;
|
||||
width: 50px;
|
||||
width: 49px;
|
||||
position: absolute;
|
||||
background-color: var(--bl-editor-gutters-bg-color);
|
||||
border-right: 1px solid var(--bl-editor-gutters-border-color);
|
||||
@ -129,11 +129,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
.operator {
|
||||
@include themeBorder(2px, #d8d8d841, #ffffff12);
|
||||
width: 20px;
|
||||
height: 75px;
|
||||
padding-top: 5px;
|
||||
background-color: var(--bl-html-color);
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
position: absolute;
|
||||
bottom: 10%;
|
||||
left: calc(50% - 0.5px);
|
||||
z-index: 4;
|
||||
text-align: center;
|
||||
transition: border 0.3s;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border: 2px solid var(--el-border-color);
|
||||
.iconbl {
|
||||
color: var(--el-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.iconbl {
|
||||
@include themeColor(#d8d8d841, #ffffff12);
|
||||
line-height: 20px;
|
||||
font-size: 16px;
|
||||
transition: color 0.3s;
|
||||
&:hover {
|
||||
color: var(--el-color-primary-light-3);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
.bl-admonish-line {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.bl-refresh-line {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.bl-eye-line {
|
||||
font-size: 18px;
|
||||
margin-bottom: 7px;
|
||||
transform: rotate(90deg);
|
||||
&:active {
|
||||
transform: scale(0.9) rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.bl-scroll {
|
||||
font-size: 19px;
|
||||
margin-left: -2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.resize-divider {
|
||||
height: 100%;
|
||||
border-left: 2px solid var(--el-border-color);
|
||||
margin-left: 1px;
|
||||
z-index: 3;
|
||||
z-index: 4;
|
||||
position: relative;
|
||||
cursor: ew-resize;
|
||||
|
||||
&:hover {
|
||||
@ -146,7 +206,7 @@
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
word-wrap: break-word;
|
||||
padding: 10px 20px 0 20px;
|
||||
padding: 10px 20px 0 30px;
|
||||
z-index: 3;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
@ -154,8 +214,6 @@
|
||||
: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -165,6 +223,5 @@
|
||||
border-top: 1px solid var(--el-border-color);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,6 @@
|
||||
|
||||
.toc-content {
|
||||
overflow-y: overlay;
|
||||
padding-top: 10px;
|
||||
|
||||
.toc-1,
|
||||
.toc-2,
|
||||
@ -38,33 +37,23 @@
|
||||
}
|
||||
|
||||
.toc-2 {
|
||||
&::before {
|
||||
content: ' ';
|
||||
}
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.toc-3 {
|
||||
&::before {
|
||||
content: ' ';
|
||||
}
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.toc-4 {
|
||||
&::before {
|
||||
content: ' ';
|
||||
}
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.toc-5 {
|
||||
&::before {
|
||||
content: ' ';
|
||||
}
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.toc-6 {
|
||||
&::before {
|
||||
content: ' ';
|
||||
}
|
||||
padding-left: 50px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -94,8 +83,9 @@
|
||||
}
|
||||
|
||||
.toc-content {
|
||||
@include box(100%, calc(100% - 20px - 20px));
|
||||
@include font(14px);
|
||||
@include box(100%, calc(100% - 130px));
|
||||
@include font(13px);
|
||||
padding-bottom: 10px;
|
||||
|
||||
.toc-1 {
|
||||
border-top: 2px solid var(--bl-preview-toc-border-color);
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { isNull } from '@renderer/assets/utils/obj'
|
||||
import Notify from '@renderer/scripts/notify'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { Ref } from 'vue'
|
||||
@ -36,6 +37,21 @@ export enum SortLevelColor {
|
||||
FOUR = '#4AA40E93'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文档是否是文章
|
||||
* @param doc
|
||||
* @returns true: 是文章; false: 不是文章
|
||||
*/
|
||||
export const isArticle = (doc: DocInfo | undefined): boolean => {
|
||||
if (isNull(doc)) {
|
||||
return false
|
||||
}
|
||||
if (isNull(doc!.type) || doc!.type != 3) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过标题等级计算颜色
|
||||
* @param level 标题等级
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="aside-upload-root">
|
||||
<el-tooltip effect="light" placement="right-start" :show-after="1000" :hide-after="0">
|
||||
<template #content>提示:<br />从这里上传, 会上传至<br />《🌌 默认文件夹》</template>
|
||||
<el-tooltip effect="light" placement="right" :show-after="1000" :hide-after="0">
|
||||
<template #content>从这里上传, 会上传至<br />《🌌 默认文件夹》</template>
|
||||
<el-upload
|
||||
name="file"
|
||||
:action="serverStore.serverUrl + uploadFileApiUrl"
|
||||
|
@ -32,9 +32,9 @@
|
||||
<div class="conf-tip">博客左上角 Logo 的访问地址,以及在浏览器标签中的 Logo。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="IPC备案号">
|
||||
<el-form-item label="ICP备案号">
|
||||
<el-input size="default" v-model="userParamForm.WEB_IPC_BEI_AN_HAO" @change="(cur: any) => updParam('WEB_IPC_BEI_AN_HAO', cur)"></el-input>
|
||||
<div class="conf-tip">如果博客作为你的域名首页,你可能需要配置 IPC 备案号</div>
|
||||
<div class="conf-tip">如果博客作为你的域名首页,你可能需要配置 ICP 备案号</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="公网安备号">
|
||||
|
@ -69,7 +69,7 @@
|
||||
:rows="2"
|
||||
resize="none"
|
||||
v-model="customBlogUrl"
|
||||
placeholder="示例: http://www.xxx.com/#/articles?articleId="
|
||||
placeholder="示例: http://www.google.com//#/articles?articleId="
|
||||
@input="customBlogUrlChange"></el-input>
|
||||
</bl-row>
|
||||
<div class="blog-url-error" style="margin-top: 10px">
|
||||
|
@ -81,7 +81,7 @@
|
||||
<bl-col class="desc" align="flex-end">
|
||||
<div>修改主题后, 再次切换日间/夜间模式可查看完整效果。</div>
|
||||
<div>
|
||||
<a href="https://chinesecolor.org/" target="_blank">主题色参考 <span class="iconbl bl-sendmail-line"></span></a>
|
||||
<a href="https://www.chinavid.com/color.html" target="_blank">主题色参考 <span class="iconbl bl-sendmail-line"></span></a>
|
||||
</div>
|
||||
</bl-col>
|
||||
</el-tab-pane>
|
||||
|
@ -7,7 +7,7 @@
|
||||
<div class="task-collapse">
|
||||
<el-collapse v-model="activeName" accordion>
|
||||
<el-collapse-item title="每日待办事项" name="1" class="collapse-item">
|
||||
<el-calendar ref="CalendarRef" class="task-day-calendar" v-model="selectDay">
|
||||
<el-calendar ref="CalendarRef" class="task-day-calendar">
|
||||
<template #header="{ date }">
|
||||
<bl-row just="space-between" class="header" style="margin: 8px 10px">
|
||||
<div class="month">{{ date.split(' ')[2] }}{{ date.split(' ')[3] }}</div>
|
||||
@ -21,7 +21,7 @@
|
||||
<template #date-cell="{ data }">
|
||||
<div class="cell-wrapper" @click="toTask(data.day, data.day, 10)">
|
||||
<div class="day">{{ data.day.split('-')[2] }}</div>
|
||||
<div v-if="getCount(data.day) > 0">
|
||||
<div v-if="getCount(data.day) != 0">
|
||||
<bl-tag>{{ getCount(data.day) }}</bl-tag>
|
||||
</div>
|
||||
</div>
|
||||
@ -35,13 +35,13 @@
|
||||
<!-- update name -->
|
||||
<el-input
|
||||
v-if="phased.updTodoName"
|
||||
:id="'phased-name-input-' + phased.todoId"
|
||||
v-model="phased.todoName"
|
||||
type="textarea"
|
||||
:id="'phased-name-input-' + phased.todoId"
|
||||
:rows="3"
|
||||
@blur="blurPhasedUpdHandle(phased.todoId!)"></el-input>
|
||||
<div v-else @dblclick="showPhasedUpdHandle(phased.todoId!)">{{ phased.todoName }}</div>
|
||||
<bl-tag v-if="phased.taskCount > 0">{{ phased.taskCount }}</bl-tag>
|
||||
<bl-tag>{{ phased.taskCountStat }}</bl-tag>
|
||||
</div>
|
||||
|
||||
<!-- add phased -->
|
||||
@ -49,6 +49,7 @@
|
||||
v-if="showPhasedAdd"
|
||||
ref="phasedAddInputRef"
|
||||
v-model="phasedAddName"
|
||||
@keyup.enter="blurPhasedAddHandle"
|
||||
@blur="blurPhasedAddHandle"
|
||||
style="margin-top: 10px"></el-input>
|
||||
<div v-else class="task-phased-add" @click="showPhasedAddHandle">新增计划</div>
|
||||
@ -58,7 +59,7 @@
|
||||
<el-collapse-item title="阶段性事项 已完成" name="3">
|
||||
<div v-for="phased in todoPhasedClose" class="task-phased" @click="toTask(phased.todoId, phased.todoName, phased.todoType)">
|
||||
{{ phased.todoName }}
|
||||
<bl-tag v-if="phased.taskCount > 0">{{ phased.taskCount }}</bl-tag>
|
||||
<bl-tag>{{ phased.taskCountStat }}</bl-tag>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
@ -101,8 +102,6 @@ useLifecycle(
|
||||
)
|
||||
|
||||
//#region ----------------------------------------< 日历 >--------------------------------------
|
||||
|
||||
const selectDay = ref()
|
||||
const CalendarRef = ref<CalendarInstance>()
|
||||
const selectDate = (val: CalendarDateType) => {
|
||||
if (!CalendarRef.value) return
|
||||
@ -128,6 +127,7 @@ const getTodos = () => {
|
||||
todoType: 10,
|
||||
today: false,
|
||||
taskCount: todo.taskCount > 0 ? todo.taskCount : 0,
|
||||
taskCountStat: '0|0|0',
|
||||
updTodoName: false
|
||||
})
|
||||
}
|
||||
@ -136,9 +136,17 @@ const getTodos = () => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo 日历每一项点击时由于内部数据变更, 都会触发查询
|
||||
* @param day
|
||||
*/
|
||||
const getCount = (day: string): number => {
|
||||
if (!todoDayMaps.value) return 0
|
||||
if (!todoDayMaps.value.get(day)) return 0
|
||||
if (!todoDayMaps.value) {
|
||||
return 0
|
||||
}
|
||||
if (!todoDayMaps.value.has(day)) {
|
||||
return 0
|
||||
}
|
||||
return todoDayMaps.value.get(day)!.taskCount
|
||||
}
|
||||
|
||||
@ -316,6 +324,7 @@ const blurPhasedUpdHandle = (todoId: string) => {
|
||||
.task-phased {
|
||||
@include flex(row, flex-start, center);
|
||||
@include font(13px, 300);
|
||||
flex-wrap: wrap;
|
||||
padding: 1px 3px;
|
||||
border-radius: 4px;
|
||||
color: var(--bl-text-color);
|
||||
@ -327,6 +336,7 @@ const blurPhasedUpdHandle = (todoId: string) => {
|
||||
|
||||
&:first-child {
|
||||
margin-top: 5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
|
@ -54,6 +54,10 @@ export interface TodoList {
|
||||
* 任务数量
|
||||
*/
|
||||
taskCount: number
|
||||
/**
|
||||
* 任务数量统计
|
||||
*/
|
||||
taskCountStat: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -105,7 +105,7 @@
|
||||
|
||||
.add-icon {
|
||||
@include font(13px, 300);
|
||||
padding-right: 3px;
|
||||
padding: 0 3px;
|
||||
color: var(--bl-text-color-light);
|
||||
cursor: pointer;
|
||||
|
||||
@ -169,10 +169,10 @@
|
||||
border: 3px solid var(--el-color-primary);
|
||||
|
||||
.iconbl {
|
||||
@include themeColor(#e4e4e4, #2c2c2c);
|
||||
display: inherit;
|
||||
margin-top: 40px;
|
||||
font-size: 90px;
|
||||
color: #e4e4e4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -113,7 +113,7 @@
|
||||
</div>
|
||||
<div class="toc-title">目录</div>
|
||||
<div class="toc-content">
|
||||
<div v-for="toc in tocList" :key="toc.index" :class="[toc.clazz]" @click="toScroll(toc.level, toc.content)">
|
||||
<div v-for="toc in tocList" :key="toc.id" :class="[toc.clazz]" @click="toScroll(toc.id)">
|
||||
{{ toc.content }}
|
||||
</div>
|
||||
</div>
|
||||
@ -185,7 +185,7 @@ const article = ref<DocInfo>({
|
||||
html: `<div style="color:#E3E3E3;width:100%;height:300px;display:flex;justify-content: center;
|
||||
align-items: center;font-size:25px;">请在左侧菜单选择文章</div>`
|
||||
})
|
||||
const tocList = ref<any>([])
|
||||
const tocList = ref<Toc[]>([])
|
||||
const defaultOpeneds = ref<string[]>([])
|
||||
const PreviewRef = ref()
|
||||
|
||||
@ -217,6 +217,10 @@ const getDocTree = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章信息
|
||||
* @param tree
|
||||
*/
|
||||
const clickCurDoc = async (tree: DocTree) => {
|
||||
// 如果选中的是文章, 则查询文章详情, 用于在编辑器中显示以及注入
|
||||
if (tree.ty == 3) {
|
||||
@ -224,6 +228,7 @@ const clickCurDoc = async (tree: DocTree) => {
|
||||
window.history.replaceState('', '', '#/articles?articleId=' + tree.i)
|
||||
nextTick(() => {
|
||||
PreviewRef.value.scrollTo({ top: 0 })
|
||||
parseTocAsync(PreviewRef.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -240,19 +245,56 @@ const getCurEditArticle = async (id: number) => {
|
||||
}
|
||||
|
||||
const then = (resp: any) => {
|
||||
if (isNull(resp.data)) return
|
||||
if (isNull(resp.data)) {
|
||||
return
|
||||
}
|
||||
article.value = resp.data
|
||||
tocList.value = JSON.parse(resp.data.toc)
|
||||
}
|
||||
if (userStore.isLogin) {
|
||||
await articleInfoApi({ id: id, showToc: true, showMarkdown: false, showHtml: true }).then((resp) => then(resp))
|
||||
await articleInfoApi({ id: id, showToc: false, showMarkdown: false, showHtml: true }).then((resp) => then(resp))
|
||||
} else {
|
||||
await articleInfoOpenApi({ id: id, showToc: true, showMarkdown: false, showHtml: true }).then((resp) => then(resp))
|
||||
await articleInfoOpenApi({ id: id, showToc: false, showMarkdown: false, showHtml: true }).then((resp) => then(resp))
|
||||
}
|
||||
}
|
||||
|
||||
const toScroll = (level: number, content: string) => {
|
||||
let id = level + '-' + content
|
||||
/**
|
||||
* 解析目录
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
const toScroll = (id: string) => {
|
||||
let elm = document.getElementById(id)
|
||||
elm?.scrollIntoView(true)
|
||||
}
|
||||
@ -349,6 +391,9 @@ const closeAll = () => {
|
||||
maskStyle.value = { display: 'none' }
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
const onresize = () => {
|
||||
let width = document.body.clientWidth
|
||||
if (width < 1100) {
|
||||
@ -552,10 +597,6 @@ const onresize = () => {
|
||||
.toc-5,
|
||||
.toc-6 {
|
||||
cursor: pointer;
|
||||
// overflow: hidden;
|
||||
// white-space: nowrap;
|
||||
// text-overflow: ellipsis;
|
||||
// white-space: pre;
|
||||
|
||||
&:hover {
|
||||
font-weight: bold;
|
||||
@ -564,7 +605,6 @@ const onresize = () => {
|
||||
|
||||
.toc-1 {
|
||||
font-size: 1.1em;
|
||||
border-top: 2px solid #eeeeee;
|
||||
margin-top: 5px;
|
||||
padding-top: 5px;
|
||||
|
||||
|
9
blossom-web/src/views/article/index.d.ts
vendored
9
blossom-web/src/views/article/index.d.ts
vendored
@ -53,3 +53,12 @@ declare type DocType = 1 | 2 | 3
|
||||
declare interface Window {
|
||||
onHtmlEventDispatch: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录结构
|
||||
*/
|
||||
declare interface Toc {
|
||||
content: string
|
||||
clazz: string
|
||||
id: string
|
||||
}
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 68 KiB |
Binary file not shown.
Before Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
Before Width: | Height: | Size: 118 KiB |
Binary file not shown.
Before Width: | Height: | Size: 78 KiB |
Binary file not shown.
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
Before Width: | Height: | Size: 106 KiB |
183
docker/README.md
183
docker/README.md
@ -7,7 +7,6 @@ docker
|
||||
├─ build 镜像构建
|
||||
| └─ Dockerfile 应用镜像构建脚本
|
||||
└─ compose docker compose 配置
|
||||
├─ blossom.yaml 包含后台应用 Blossom-backend
|
||||
└─ blossom-mysql8.yaml 包含后台应用 Blossom-backend 与 MySql8
|
||||
```
|
||||
|
||||
@ -68,12 +67,6 @@ docker run -d \
|
||||
-v /home/bl/:/home/bl/ \
|
||||
# 启动的镜像名称
|
||||
jasminexzzz/blossom:latest \
|
||||
# 使用的后台配置文件,不需要修改
|
||||
--spring.profiles.active=prod \
|
||||
# 配置图片上传后对应生成的访问URL,需要以/pic/结尾。注意,该访问URL需要与访问后台的域名和端口相同
|
||||
--project.iaas.blos.domain="http://127.0.0.1:9999/pic/" \
|
||||
# 配置图片保存的磁盘路径,请使用 -v 将该路径挂载为本地路径,如上方示例
|
||||
--project.iaas.blos.default-path="/home/bl/img/" \
|
||||
# 配置数据库访问地址
|
||||
--spring.datasource.url="jdbc:mysql://192.168.31.99:3306/blossom?useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true&allowMultiQueries=true&useSSL=false&&serverTimezone=GMT%2B8" \
|
||||
# 配置数据库用户名
|
||||
@ -92,55 +85,7 @@ docker run -d --name blossom-backend -p 9999:9999 -v /home/bl/:/home/bl/ jasmine
|
||||
|
||||
可以使用 docker compose 单独拉取应用镜像,或者连同 MySQL 一起拉取构建。下列示例均可在项目`/docker/compose`目录下查看
|
||||
|
||||
## 1. 只拉取应用镜像示例
|
||||
|
||||
该 docker compose 不包含 MySQL,需要你自行安装 MySQL,并在 MySQL 中先创建一个数据库,数据库名称需要与启动容器命令中参数SPRING_DATASOURCE_URL配置的数据库名称相同,如果不需要自定义数据库名称,你可以直接使用如下语句创建数据库:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE `blossom` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
|
||||
```
|
||||
|
||||
docker compose 文件示例
|
||||
|
||||
|
||||
```yml
|
||||
version: "3.8"
|
||||
services:
|
||||
blossom:
|
||||
image: jasminexzzz/blossom:latest
|
||||
container_name: blossom-backend
|
||||
volumes:
|
||||
# 【需修改】挂载图片保存路径,如果是windows环境,可以使用/c/home/bl/来指定磁盘
|
||||
- /d/blossom/bl/:/home/bl/
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: prod
|
||||
# 【需修改】配置数据库访问地址
|
||||
SPRING_DATASOURCE_URL: jdbc:mysql://192.168.31.99:3306/blossom?useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true&allowMultiQueries=true&useSSL=false&&serverTimezone=GMT%2B8
|
||||
# 【需修改】配置数据库用户名
|
||||
SPRING_DATASOURCE_USERNAME: root
|
||||
# 【需修改】配置数据库密码
|
||||
SPRING_DATASOURCE_PASSWORD: jasmine888
|
||||
# 【需修改】配置图片上传后对应生成的访问 URL,需要以/pic/结尾。注意,该访问 URL 需要与访问后台的域名和端口相同
|
||||
PROJECT_IAAS_BLOS_DOMAIN: http://localhost:9999/pic/
|
||||
# 配置图片保存的磁盘路径,并在 volumes 中将该路径挂载为本地路径,如上方 volumes 中的示例
|
||||
PROJECT_IAAS_BLOS_DEFAULT-PATH: /home/bl/img/
|
||||
ports:
|
||||
- "9999:9999"
|
||||
restart: always
|
||||
```
|
||||
|
||||
|
||||
启动不含有 MYSQL 的 Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose -f docker/compose/blossom.yaml up -d
|
||||
```
|
||||
|
||||
> 该方式与使用公共镜像基本相同
|
||||
|
||||
---
|
||||
|
||||
## 2. 拉取应用镜像与 MySQL 镜像示例
|
||||
## 1. 拉取应用镜像与 MySQL 镜像示例
|
||||
|
||||
该 docker compose 包含 MySQL,MySQL 容器在初始化时会自动创建数据库 Blossom,但你需要挂载 MySQL 文件到宿主机,防止数据丢失。
|
||||
|
||||
@ -160,17 +105,12 @@ services:
|
||||
# 【需修改】挂载图片保存路径,如果是windows环境,可以使用/c/home/bl/img/来指定磁盘
|
||||
- /d/blossom/bl/:/home/bl/
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: prod
|
||||
# 配置数据库访问地址
|
||||
SPRING_DATASOURCE_URL: jdbc:mysql://blmysql:3306/blossom?useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true&allowMultiQueries=true&useSSL=false&&serverTimezone=GMT%2B8
|
||||
# 【需修改】配置数据库用户名
|
||||
SPRING_DATASOURCE_USERNAME: root
|
||||
# 【需修改】配置数据库密码
|
||||
SPRING_DATASOURCE_PASSWORD: jasmine888
|
||||
# 【需修改】配置图片上传后对应生成的访问 URL,需要以/pic/结尾。注意,该访问域名(IP:端口)需要与访问后台的域名(IP:端口)相同
|
||||
PROJECT_IAAS_BLOS_DOMAIN: http://localhost:9999/pic/
|
||||
# 配置图片保存的磁盘路径,并在 volumes 中将该路径挂载为本地路径,如上方 volumes 中的示例
|
||||
PROJECT_IAAS_BLOS_DEFAULT-PATH: /home/bl/img/
|
||||
ports:
|
||||
- "9999:9999"
|
||||
networks:
|
||||
@ -218,127 +158,6 @@ services:
|
||||
docker compose -f docker/compose/blossom-mysql8.yaml up -d
|
||||
```
|
||||
|
||||
# 三、使用 Dockerfile 构建应用镜像
|
||||
|
||||
> 该方式构建镜像只包含后台应用,不包含数据库在内
|
||||
|
||||
## 1. 后台工程打包
|
||||
|
||||
需要安装如下工具
|
||||
|
||||
- Docker 运行环境
|
||||
- git 运行环境
|
||||
- Maven 3.6.1+
|
||||
|
||||
克隆代码仓库,然后进入到`blossom-backend/`目录下,运行如下命令编译打包后台工程
|
||||
|
||||
```bash
|
||||
git clone https://github.com/blossom-editor/blossom.git
|
||||
maven clean package
|
||||
```
|
||||
|
||||
打包成功如下图:
|
||||
|
||||
![backend_package](../doc/imgs/deploy/backend_package.png)
|
||||
|
||||
进入到`blossom-backend\backend\target`路径下检查是否包含`backend-blossom.jar`文件,如果包含则打包成功。
|
||||
|
||||
## 2. 构建本地镜像
|
||||
|
||||
进入到项目根目录下,运行如下命令
|
||||
|
||||
```bash
|
||||
docker build -t <自定义前缀>/<自定义镜像名称>:<自定义镜像标签> -f docker/build/Dockerfile .
|
||||
|
||||
# 示例1
|
||||
docker build -t self/blossom:self -f docker/build/Dockerfile .
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 注意:一定要在项目根目录下运行`docker build`命令,否则无法找到后台 jar 包文件
|
||||
|
||||
> [!NOTE]
|
||||
> 在后台项目`blossom-backend\`路径下也包含一个 Dockerfile 文件,只有构建命令略有不同,具体参阅`blossom-backend\README.md`文件
|
||||
|
||||
## 3. 创建数据库
|
||||
|
||||
你需要在 MySQL 中先创建一个数据库,数据库名称需要与启动容器命令中参数--spring.datasource.url配置的数据库名称相同,如果不需要自定义数据库名称,你可以直接使用如下语句创建数据库:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE `blossom` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
|
||||
```
|
||||
|
||||
## 4. 启动容器
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
# 容器名称
|
||||
--name blossom-backend \
|
||||
# 指定端口映射
|
||||
-p 9999:9999 \
|
||||
# 挂载图片保存路径,如果是windows环境,可以使用/c/home/bl/来指定磁盘
|
||||
-v /home/bl/:/home/bl/ \
|
||||
# 启动的镜像名称
|
||||
jasminexzzz/blossom:latest \
|
||||
# 使用的后台配置文件,不需要修改
|
||||
--spring.profiles.active=prod \
|
||||
# 配置图片上传后对应生成的访问URL,需要以/pic/结尾。注意,该访问URL需要与访问后台的域名和端口相同
|
||||
--project.iaas.blos.domain="http://127.0.0.1:9999/pic/" \
|
||||
# 配置图片保存的磁盘路径,请使用 -v 将该路径挂载为本地路径,如上方示例
|
||||
--project.iaas.blos.default-path="/home/bl/img/" \
|
||||
# 配置数据库访问地址
|
||||
--spring.datasource.url="jdbc:mysql://192.168.31.99:3306/blossom?useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true&allowMultiQueries=true&useSSL=false&&serverTimezone=GMT%2B8" \
|
||||
# 配置数据库用户名
|
||||
--spring.datasource.username=root \
|
||||
# 配置数据库密码
|
||||
--spring.datasource.password=jasmine888
|
||||
```
|
||||
|
||||
# 四、使用 Idea Cloud Toolkit 插件部署
|
||||
|
||||
> 该部署方式适合修改代码后部署在物理机器中。
|
||||
|
||||
在 Idea 中安装插件
|
||||
|
||||
![idea_plugin1](../doc/imgs/deploy/idea_plugin1.png)
|
||||
|
||||
配置目标机器地址
|
||||
|
||||
![idea_plugin1](../doc/imgs/deploy/idea_plugin2.png)
|
||||
|
||||
![idea_plugin1](../doc/imgs/deploy/idea_plugin3.png)
|
||||
|
||||
新增启动项
|
||||
|
||||
![idea_plugin1](../doc/imgs/deploy/idea_plugin4.png)
|
||||
|
||||
配置启动项
|
||||
|
||||
![idea_plugin1](../doc/imgs/deploy/idea_plugin5.png)
|
||||
|
||||
1. 需要上传的`jar`包地址
|
||||
2. 目标机器的IP,点击右侧+号添加
|
||||
3. 上传到目标机器的路径
|
||||
4. 上传后执行的命令,例如执行下方的重启脚本
|
||||
5. 上传前执行的命令
|
||||
|
||||
重启脚本示例`restart-sprigboot.sh`
|
||||
```bash
|
||||
#!/dash
|
||||
# 重启 blossom
|
||||
pid=`ps aux | grep backend-blossom.jar | grep -v grep | awk '{print $2}'`
|
||||
echo "进程ID : " $pid
|
||||
kill -9 $pid
|
||||
echo "进程" $pid "已被杀死"
|
||||
echo "开始重启 backend-blossom 服务器"
|
||||
# 注意修改对应路径
|
||||
nohup java -Xms1024m -Xmx1024m -jar /usr/local/jasmine/blossom/backend/backend-blossom.jar &
|
||||
echo "backend-blossom 正在启动,请查看日志 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓"
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -1,23 +0,0 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
blossom:
|
||||
image: jasminexzzz/blossom:latest
|
||||
container_name: blossom-backend
|
||||
volumes:
|
||||
# 【需修改】挂载图片保存路径,如果是windows环境,可以使用/c/home/bl/img/来指定磁盘
|
||||
- /d/blossom/bl/:/home/bl/
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: prod
|
||||
# 【需修改】配置数据库访问地址
|
||||
SPRING_DATASOURCE_URL: jdbc:mysql://192.168.31.99:3306/blossom?useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true&allowMultiQueries=true&useSSL=false&&serverTimezone=GMT%2B8
|
||||
# 【需修改】配置数据库用户名
|
||||
SPRING_DATASOURCE_USERNAME: root
|
||||
# 【需修改】配置数据库密码
|
||||
SPRING_DATASOURCE_PASSWORD: jasmine888
|
||||
# 【需修改】配置图片上传后对应生成的访问 URL,需要以/pic/结尾。注意,该访问 URL 需要与访问后台的域名和端口相同
|
||||
PROJECT_IAAS_BLOS_DOMAIN: http://localhost:9999/pic/
|
||||
# 配置图片保存的磁盘路径,并在 volumes 中将该路径挂载为本地路径,如上方 volumes 中的示例
|
||||
PROJECT_IAAS_BLOS_DEFAULT-PATH: /home/bl/img/
|
||||
ports:
|
||||
- "9999:9999"
|
||||
restart: always
|
Loading…
Reference in New Issue
Block a user