Compare commits
No commits in common. "main" and "release/v1.1.0" have entirely different histories.
main
...
release/v1
@ -56,12 +56,6 @@
|
|||||||
2. 监听 Split 的数值变化,调整 MarkdownEditor 的宽度
|
2. 监听 Split 的数值变化,调整 MarkdownEditor 的宽度
|
||||||
3. 监听窗口 resize 事件,调整 MarkdownEditor 的高度和宽度
|
3. 监听窗口 resize 事件,调整 MarkdownEditor 的高度和宽度
|
||||||
|
|
||||||
### 改用 [md-editor-v3](https://imzbf.github.io/md-editor-v3/zh-CN) 编辑器组件
|
|
||||||
|
|
||||||
1. `npm install md-editor-v3 --save`
|
|
||||||
2. 在 src/components/MainEditor.vue 组件里使用 md-editor-v3 组件
|
|
||||||
3. 实现保存功能;自定义工具栏实现显示/隐藏目录以及发布等功能
|
|
||||||
|
|
||||||
### 调试应用
|
### 调试应用
|
||||||
|
|
||||||
- 开发调试:`npm run tauri dev`
|
- 开发调试:`npm run tauri dev`
|
||||||
|
|||||||
1383
package-lock.json
generated
1383
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,11 +14,6 @@
|
|||||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||||
"@tauri-apps/plugin-fs": "^2.2.1",
|
"@tauri-apps/plugin-fs": "^2.2.1",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@vavt/v3-extension": "^3.0.0",
|
|
||||||
"js-md5": "^0.8.3",
|
|
||||||
"markdown-it-link-attributes": "^4.0.1",
|
|
||||||
"md-editor-v3": "^5.5.0",
|
|
||||||
"plantuml-encoder": "^1.4.0",
|
|
||||||
"scriptjs": "^2.5.9",
|
"scriptjs": "^2.5.9",
|
||||||
"view-ui-plus": "^1.3.19",
|
"view-ui-plus": "^1.3.19",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
|
|||||||
@ -10,9 +10,6 @@
|
|||||||
"opener:default",
|
"opener:default",
|
||||||
"fs:default",
|
"fs:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
{
|
|
||||||
"identifier": "core:window:allow-destroy"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identifier": "fs:scope",
|
"identifier": "fs:scope",
|
||||||
"allow": [
|
"allow": [
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "markdown-editor",
|
"productName": "markdown-editor",
|
||||||
"version": "2.2.0",
|
"version": "1.1.0",
|
||||||
"identifier": "com.markdown-editor.app",
|
"identifier": "com.markdown-editor.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
305
src/App.vue
305
src/App.vue
@ -1,35 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<Split v-model="split" min="5px">
|
<div>
|
||||||
<template #left>
|
<Split v-model="split">
|
||||||
<div class="split-left" :class="hiddenSplitLeft">
|
<template #left>
|
||||||
<LeftSidebar v-model:rootFolderPath="rootFolderPath" v-model:currentFilePath="currentFilePath"
|
<div class="split-left" :class="hiddenSplitLeft">
|
||||||
:confirmSwitchingFile="confirmSwitchingFile">
|
<LeftSidebar v-model:rootFolderPath="rootFolderPath" v-model:currentFilePath="currentFilePath">
|
||||||
</LeftSidebar>
|
</LeftSidebar>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<div ref="splitTrigger" class="split-trigger"></div>
|
<div class="split-trigger"></div>
|
||||||
</template>
|
</template>
|
||||||
<template #right>
|
<template #right>
|
||||||
<div ref="splitRight" class="split-right">
|
<div ref="splitRight" class="split-right">
|
||||||
<MainEditor ref="mainEditor" v-model:markdownCode="markdownCode"
|
<MarkdownEditor ref="markdownRef" width="100%" height="100%" :autoInit="false"
|
||||||
v-model:leftSidebarState="leftSidebarState" :save="writeFileContent" @exportPDF="exportMarkdownPDF">
|
:markdownCode="markdownCode" :imageUpload="true" :imageUploadURL="imageUploadURLOfEditor"
|
||||||
</MainEditor>
|
:imageUploadURLChange="changeImageUploadURL" :onload="reloadEditorHeight"
|
||||||
</div>
|
:onFullScreenExit="handleWindowResize" @update:markdownCode="newCode => markdownCode = newCode"
|
||||||
</template>
|
:appendToolbar="appendToolbar" />
|
||||||
</Split>
|
</div>
|
||||||
|
</template>
|
||||||
|
</Split>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import MarkdownEditor from './components/MarkdownEditor.vue'
|
||||||
import LeftSidebar from './components/LeftSidebar.vue'
|
import LeftSidebar from './components/LeftSidebar.vue'
|
||||||
import MainEditor from './components/MainEditor.vue'
|
import { ref, watch, useTemplateRef, onMounted, onUnmounted } from "vue";
|
||||||
import { ref, watch, useTemplateRef, nextTick, onMounted, onUnmounted } from "vue";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { readTextFile, writeTextFile, exists } from '@tauri-apps/plugin-fs';
|
||||||
import { readTextFile, writeTextFile, writeFile, exists } from '@tauri-apps/plugin-fs';
|
import { Message } from 'view-ui-plus'
|
||||||
import { save, confirm } from '@tauri-apps/plugin-dialog';
|
|
||||||
import { Message, Notice, Modal } from 'view-ui-plus'
|
|
||||||
import { GetSingleArticle, UpdateArticle } from '/src/utils/userHandler';
|
|
||||||
|
|
||||||
// 原始示例数据,仅供参考
|
// 原始示例数据,仅供参考
|
||||||
const greetMsg = ref("");
|
const greetMsg = ref("");
|
||||||
@ -40,37 +40,24 @@ async function greet() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用 Split 组件动态控制页面左右布局
|
// 使用 Split 组件动态控制页面左右布局
|
||||||
const defaultSplit = 0.15; // 默认左侧边栏和右侧区域的比例为 1.5:8.5
|
const split = ref(0.2);
|
||||||
const initSplit = localStorage.getItem('splitValueOfFolderTree');
|
|
||||||
const split = initSplit === null ? ref(defaultSplit) : ref(Number(initSplit));
|
|
||||||
const splitTrigger = useTemplateRef('splitTrigger');
|
|
||||||
const splitRight = useTemplateRef('splitRight');
|
const splitRight = useTemplateRef('splitRight');
|
||||||
const splitRightWidth = ref(0);
|
const hiddenSplitLeft = ref("");
|
||||||
const hiddenSplitLeft = ref('');
|
|
||||||
const leftSidebarState = split.value > 0 ? ref('open') : ref('close');
|
|
||||||
watch(split, (newSplit) => {
|
watch(split, (newSplit) => {
|
||||||
localStorage.setItem('splitValueOfFolderTree', newSplit);
|
|
||||||
splitRightWidth.value = splitRight.value.offsetWidth;
|
|
||||||
// 当 split 小于某个值时,隐藏左边布局
|
// 当 split 小于某个值时,隐藏左边布局
|
||||||
if (newSplit < 0.05) {
|
if (newSplit < 0.05) {
|
||||||
split.value = 0;
|
split.value = 0.01
|
||||||
leftSidebarState.value = 'close';
|
if (hiddenSplitLeft.value == "") {
|
||||||
if (hiddenSplitLeft.value == '') {
|
hiddenSplitLeft.value = "hidden"
|
||||||
hiddenSplitLeft.value = 'hidden';
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
leftSidebarState.value = 'open';
|
if (hiddenSplitLeft.value == "hidden") {
|
||||||
if (hiddenSplitLeft.value == 'hidden') {
|
hiddenSplitLeft.value = ""
|
||||||
hiddenSplitLeft.value = '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
watch(leftSidebarState, (state) => {
|
// 动态调整 MarkdownEditor 的宽度。如果不执行 reset 的话,CodeMirror 内部编辑器不会自适应宽度
|
||||||
if (state == 'open') {
|
reloadEditorWidth()
|
||||||
split.value = defaultSplit;
|
|
||||||
} else {
|
|
||||||
split.value = 0;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听根目录路径。如果之前已经选过目录,初始界面直接加载该目录
|
// 监听根目录路径。如果之前已经选过目录,初始界面直接加载该目录
|
||||||
@ -81,178 +68,113 @@ watch(rootFolderPath, (newRootPath) => {
|
|||||||
|
|
||||||
// <FolderTree> 完成文件树渲染后,点击文件得到 currentFilePath
|
// <FolderTree> 完成文件树渲染后,点击文件得到 currentFilePath
|
||||||
const currentFilePath = ref(localStorage.getItem('currentSelectedFilePath') || "");
|
const currentFilePath = ref(localStorage.getItem('currentSelectedFilePath') || "");
|
||||||
let switchFilePath = true; // 切换文件时,避免触发 markdownCode 的 watch 事件
|
|
||||||
// 读取 currentFilePath 的文件内容,填充到 MarkdownEditor 之中
|
// 读取 currentFilePath 的文件内容,填充到 MarkdownEditor 之中
|
||||||
watch(currentFilePath, async (newFilePath) => {
|
watch(currentFilePath, async (newFilePath) => {
|
||||||
switchFilePath = true;
|
|
||||||
localStorage.setItem('currentSelectedFilePath', newFilePath);
|
localStorage.setItem('currentSelectedFilePath', newFilePath);
|
||||||
await readFileContent(newFilePath);
|
await readFileContent(newFilePath);
|
||||||
})
|
})
|
||||||
// 切换文件时,如果当前文件的变动未保存,允许用户取消切换动作,保存当前文件内容
|
|
||||||
const confirmSwitchingFile = () => {
|
// 加载 Markdown 编辑器
|
||||||
return new Promise((resolve) => {
|
const markdownRef = ref(null);
|
||||||
// 如果当前文件内容未被修改,允许切换文件
|
const markdownCode = ref("# Hello Markdown");
|
||||||
if (currentFileSaved()) {
|
const appendToolbar = [
|
||||||
resolve(true);
|
{
|
||||||
return;
|
name: "|",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "save-markdown-code",
|
||||||
|
icon: "fa-save", // font-awesome icon. 需确保在 editormd.css 中已定义
|
||||||
|
title: "保存当前内容",
|
||||||
|
shortcut: [
|
||||||
|
navigator.userAgent.toUpperCase().indexOf('MAC') >= 0 ? "Cmd-S" : "Ctrl-S"
|
||||||
|
],
|
||||||
|
handler: () => {
|
||||||
|
writeFileContent();
|
||||||
|
},
|
||||||
|
nofocus: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "publish",
|
||||||
|
icon: "fa-cloud-upload",
|
||||||
|
title: "发布文章",
|
||||||
|
handler: () => {
|
||||||
|
// TODO 调用接口将 markdown 源码或者 HTML 代码发布到云端
|
||||||
|
Message.warning('抱歉,该功能暂未开放');
|
||||||
|
},
|
||||||
|
nofocus: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// 支持从 localStorage 读写 imageUploadURL
|
||||||
|
const imageUploadURLOfEditor = ref(localStorage.getItem('imageUploadURLOfEditor') || "");
|
||||||
|
function changeImageUploadURL(newImageUploadURL) {
|
||||||
|
console.log("new imageUploadURL: ", newImageUploadURL);
|
||||||
|
localStorage.setItem('imageUploadURLOfEditor', newImageUploadURL);
|
||||||
|
}
|
||||||
|
async function initMarkdownEditor() {
|
||||||
|
// 打开上一次选择的文件的内容
|
||||||
|
if (currentFilePath.value.length > 0) {
|
||||||
|
try {
|
||||||
|
markdownCode.value = await readTextFile(currentFilePath.value);
|
||||||
|
Message.success('已读取文件内容,并加载到编辑器中:' + currentFilePath.value);
|
||||||
|
} catch (err) {
|
||||||
|
Message.error('文件读取失败:' + err);
|
||||||
}
|
}
|
||||||
// 如果当前文件内容被修改,允许用户取消切换,可以保存后再切换
|
}
|
||||||
Modal.confirm({
|
markdownRef.value.initEditor()
|
||||||
title: '当前文件的内容变动未保存',
|
|
||||||
content: `<p><b>${currentFilePath.value}</b> 内容被修改但仍未保存,确认切换文件?<br/><br/>注:确认切换之后,内容变动会丢失</p>`,
|
|
||||||
okText: "仍然切换",
|
|
||||||
cancelText: "取消切换",
|
|
||||||
onOk: () => {
|
|
||||||
resolve(true);
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
resolve(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const currentFileSaved = () => {
|
// 矫正 markdown 编辑器的高度
|
||||||
// 判断当前文件内容是否被修改但未保存,如果已保存则返回 true
|
function reloadEditorHeight() {
|
||||||
const lastSaveTimeValue = lastSaveTime.get(currentFilePath.value);
|
markdownRef.value.resetHeight(window.innerHeight);
|
||||||
const isUpdated = lastSaveTimeValue && lastUpdateTime.value > lastSaveTimeValue;
|
|
||||||
return !isUpdated;
|
|
||||||
}
|
}
|
||||||
const getFileNameFromFilePath = (filePath) => {
|
// 矫正 markdown 编辑器的宽度
|
||||||
const fullFileName = filePath.split('/').pop();
|
function reloadEditorWidth() {
|
||||||
const splitResult = fullFileName.split(".");
|
// 可以使用 splitRight.value.offsetWidth 也可以使用 100% 来调整
|
||||||
splitResult.pop(); // 丢掉后缀
|
const width = splitRight.value.offsetWidth;
|
||||||
return splitResult.join('.');
|
markdownRef.value.resetWidth("100%");
|
||||||
|
}
|
||||||
|
// 监听页面宽度和高度,调整 markdown 编辑器的高宽度
|
||||||
|
const handleWindowResize = () => {
|
||||||
|
reloadEditorHeight();
|
||||||
|
reloadEditorWidth();
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainEditor = ref(null);
|
|
||||||
const markdownCode = ref("# Hello Markdown");
|
|
||||||
const lastSaveTime = new Map(); // 记录每个文件的最后保存时间
|
|
||||||
const lastUpdateTime = ref(0); // 记录当前文件的最后更新时间
|
|
||||||
watch(markdownCode, (newMarkdownCode) => {
|
|
||||||
if (switchFilePath === true) {
|
|
||||||
switchFilePath = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastUpdateTime.value = new Date().getTime();
|
|
||||||
console.log("code be updated");
|
|
||||||
})
|
|
||||||
async function readFileContent(filePath) {
|
async function readFileContent(filePath) {
|
||||||
if (window.__TAURI_INTERNALS__ === undefined) {
|
|
||||||
// 调用接口读取 currentFilePath 文件内容
|
|
||||||
const match = filePath.match(/(\d+)$/);
|
|
||||||
const articleID = match ? parseInt(match[1], 10) : null;
|
|
||||||
GetSingleArticle(articleID).then((data) => {
|
|
||||||
markdownCode.value = data.mdCode;
|
|
||||||
currentFilePath.value = filePath;
|
|
||||||
lastSaveTime.set(filePath, new Date().getTime());
|
|
||||||
Message.success('已读取文件内容,并加载到编辑器中:' + filePath);
|
|
||||||
}).catch((error) => {
|
|
||||||
Message.error(`调用异常: ${error}`);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const fileContent = await readTextFile(filePath);
|
markdownRef.value.setMarkdownCode(await readTextFile(filePath));
|
||||||
markdownCode.value = fileContent;
|
|
||||||
currentFilePath.value = filePath;
|
currentFilePath.value = filePath;
|
||||||
lastSaveTime.set(filePath, new Date().getTime());
|
|
||||||
Message.success('已读取文件内容,并加载到编辑器中:' + filePath);
|
Message.success('已读取文件内容,并加载到编辑器中:' + filePath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Message.error('文件读取失败:' + err);
|
Message.error('文件读取失败:' + err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function writeFileContent(markdownCode) {
|
async function writeFileContent() {
|
||||||
// 如果未选择任何文件,则不触发写文件事件
|
// 如果未选择任何文件,则不触发写文件事件
|
||||||
if (currentFilePath.value.length == 0) {
|
if (currentFilePath.value.length == 0) {
|
||||||
Message.warning('当前编辑器暂未关联目标文件,请在左侧打开目录并选择一个文件')
|
Message.warning('当前编辑器暂未关联目标文件,请在左侧打开目录并选择一个文件')
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (window.__TAURI_INTERNALS__ === undefined) {
|
const exist = await exists(currentFilePath.value);
|
||||||
// 调用接口更新 currentFilePath 文件内容
|
if (!exist) {
|
||||||
const match = currentFilePath.value.match(/(\d+)$/);
|
throw new Error(currentFilePath.value + " 文件不存在.");
|
||||||
const articleID = match ? parseInt(match[1], 10) : null;
|
|
||||||
UpdateArticle(articleID, null, markdownCode).then((data) => {
|
|
||||||
Message.success('更新成功');
|
|
||||||
}).catch((error) => {
|
|
||||||
Message.error(`更新异常: ${error}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const exist = await exists(currentFilePath.value);
|
|
||||||
if (!exist) {
|
|
||||||
throw new Error(currentFilePath.value + " 文件不存在.");
|
|
||||||
}
|
|
||||||
await writeTextFile(currentFilePath.value, markdownCode);
|
|
||||||
Message.success('文件更新成功:' + currentFilePath.value);
|
|
||||||
}
|
}
|
||||||
lastSaveTime.set(currentFilePath.value, new Date().getTime());
|
await writeTextFile(currentFilePath.value, markdownRef.value.getMarkdownCode())
|
||||||
|
Message.success('文件更新成功:' + currentFilePath.value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Message.error('文件更新失败:' + err);
|
Message.error('文件更新失败:' + err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function exportMarkdownPDF(pdfBuffer) {
|
|
||||||
let fileName = 'markdown.pdf';
|
|
||||||
if (currentFilePath.value.length > 0) {
|
|
||||||
fileName = getFileNameFromFilePath(currentFilePath.value) + '.pdf';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
onMounted(() => {
|
||||||
const filePath = await save({
|
window.addEventListener('resize', handleWindowResize);
|
||||||
title: '保存 PDF', // 对话框标题
|
initMarkdownEditor();
|
||||||
defaultPath: fileName, // 保存的文件名称
|
})
|
||||||
});
|
|
||||||
|
|
||||||
if (filePath) {
|
|
||||||
await writeFile(filePath, new Uint8Array(pdfBuffer));
|
|
||||||
Notice.success({
|
|
||||||
title: '导出成功',
|
|
||||||
desc: 'PDF 已保存至 ' + filePath,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('用户主动取消');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
Notice.error({
|
|
||||||
title: '导出失败',
|
|
||||||
desc: '请将文件保存在用户有权限的目录之下',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resizeHandler = () => {
|
|
||||||
// splitRight 的宽度为视口宽度右边百分比,再减去 splitTrigger 的宽度
|
|
||||||
splitRightWidth.value = window.innerWidth * (1 - split.value) - splitTrigger.value.offsetWidth;
|
|
||||||
}
|
|
||||||
onMounted(async () => {
|
|
||||||
// 使用 nextTick 确保页面组件已完成挂载和渲染
|
|
||||||
await nextTick();
|
|
||||||
resizeHandler();
|
|
||||||
// 监听页面宽度和高度,调整 markdown 编辑器的高宽度
|
|
||||||
window.addEventListener('resize', resizeHandler);
|
|
||||||
// 打开上一次选择的文件的内容
|
|
||||||
if (currentFilePath.value.length > 0) {
|
|
||||||
await readFileContent(currentFilePath.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听窗口关闭事件。如果当前文件内容未保存,允许取消关闭事件
|
|
||||||
await getCurrentWindow().onCloseRequested(async (event) => {
|
|
||||||
if (currentFileSaved()) {
|
|
||||||
return; // 如果当前文件已保存,允许关闭窗口
|
|
||||||
}
|
|
||||||
const confirmed = await confirm('当前文件内容未保存,是否确认关闭窗口?');
|
|
||||||
if (!confirmed) {
|
|
||||||
// user did not confirm closing the window; let's prevent it
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', resizeHandler);
|
window.removeEventListener('resize', handleWindowResize);
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
body {
|
body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* 禁用所有方向滚动 */
|
/* 禁用所有方向滚动 */
|
||||||
@ -267,9 +189,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.split-trigger {
|
.split-trigger {
|
||||||
width: 2px;
|
width: 5px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: #e6e6e6;
|
background-color: gray;
|
||||||
}
|
}
|
||||||
|
|
||||||
.split-trigger:hover {
|
.split-trigger:hover {
|
||||||
@ -277,10 +199,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.split-right {
|
.split-right {
|
||||||
margin-left: 2px;
|
margin-left: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<style></style>
|
||||||
|
|||||||
@ -60,14 +60,6 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: [],
|
default: [],
|
||||||
},
|
},
|
||||||
// 切换节点前的确认函数。返回 Promise 对象,resolve(true) 表示切换节点,resolve(false) 表示取消切换节点
|
|
||||||
confirmSwitchingNode: {
|
|
||||||
type: Function,
|
|
||||||
required: false,
|
|
||||||
default: () => new Promise((resolve) => {
|
|
||||||
resolve(true);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -228,22 +220,15 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.confirmSwitchingNode().then((result) => {
|
// 清除其他选中状态,为当前节点增加选中状态
|
||||||
// 如果用户取消了切换文件,则不做任何操作
|
let selectedNodes = this.$refs.tree.getSelectedNodes()
|
||||||
if (result === false) {
|
selectedNodes.forEach(element => {
|
||||||
return;
|
element.selected = false
|
||||||
}
|
|
||||||
|
|
||||||
// 清除其他选中状态,为当前节点增加选中状态
|
|
||||||
let selectedNodes = this.$refs.tree.getSelectedNodes();
|
|
||||||
selectedNodes.forEach(element => {
|
|
||||||
element.selected = false;
|
|
||||||
});
|
|
||||||
nodeRenderData.selected = true;
|
|
||||||
|
|
||||||
// 向父组件暴露当前选中的文件
|
|
||||||
this.$emit('file-selected', nodeRenderData);
|
|
||||||
});
|
});
|
||||||
|
nodeRenderData.selected = true
|
||||||
|
|
||||||
|
// 向父组件暴露当前选中的文件
|
||||||
|
this.$emit('file-selected', nodeRenderData);
|
||||||
},
|
},
|
||||||
// 在父节点插入子节点
|
// 在父节点插入子节点
|
||||||
insertTreeNode(parentRenderData, nodeData, index) {
|
insertTreeNode(parentRenderData, nodeData, index) {
|
||||||
@ -1,23 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<Space direction="vertical" style="margin-left: 5px; margin-top: 5px;">
|
<SelectFolder :rootPath="rootFolderPath" :nodeDataCallback="appendDataForNode" @update:rootPath="changeRootPath"
|
||||||
<Space>
|
@folder-selected="showFileTree" />
|
||||||
<Button v-if="!loginStatus" type="primary" shape="circle" icon="md-person"
|
<FolderTree ref="folderTreeRef" :treeData="folderTreeData" :expandLevel="1" :selectedNode="[currentFilePath]"
|
||||||
@click="showLoginModal = true"></Button>
|
:specifyFileSuffix="['md']" @file-selected="fileSelected" />
|
||||||
<Button v-else type="error" shape="circle" icon="md-log-out" @click="logout"></Button>
|
|
||||||
<SelectFolder ref="selectFolderRef" :rootPath="rootFolderPath" :nodeDataCallback="appendDataForNode"
|
|
||||||
@update:rootPath="changeRootPath" @folder-selected="showFileTree" />
|
|
||||||
</Space>
|
|
||||||
<FolderTree ref="folderTreeRef" :treeData="folderTreeData" :expandLevel="1" :selectedNode="[currentFilePath]"
|
|
||||||
:confirmSwitchingNode="confirmSwitchingFile" :specifyFileSuffix="['md']" @file-selected="fileSelected" />
|
|
||||||
</Space>
|
|
||||||
<UserLogin ref="loginRef" v-model:visible="showLoginModal" v-model:loginStatus="loginStatus"></UserLogin>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import UserLogin from './UI/UserLogin.vue';
|
import SelectFolder from './SelectFolder.vue';
|
||||||
import SelectFolder from './UI/SelectFolder.vue';
|
import FolderTree from './FolderTree.vue';
|
||||||
import FolderTree from './UI/FolderTree.vue';
|
import { ref } from 'vue';
|
||||||
import { ref, watch } from 'vue';
|
|
||||||
import { mkdir, remove, create, exists, rename } from '@tauri-apps/plugin-fs';
|
import { mkdir, remove, create, exists, rename } from '@tauri-apps/plugin-fs';
|
||||||
import { Message } from 'view-ui-plus'
|
import { Message } from 'view-ui-plus'
|
||||||
|
|
||||||
@ -32,11 +23,6 @@ defineProps({
|
|||||||
required: false,
|
required: false,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
confirmSwitchingFile: {
|
|
||||||
type: Function,
|
|
||||||
required: false,
|
|
||||||
default: undefined,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@ -44,22 +30,6 @@ const emit = defineEmits([
|
|||||||
'update:rootFolderPath'
|
'update:rootFolderPath'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 调用登录组件,获取登录状态,以及点击可登出
|
|
||||||
const loginRef = ref(null);
|
|
||||||
const loginStatus = ref(false);
|
|
||||||
const showLoginModal = ref(false);
|
|
||||||
const logout = () => {
|
|
||||||
loginRef.value.logout();
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectFolderRef = ref(null)
|
|
||||||
watch(loginStatus, (newStatus) => {
|
|
||||||
if (newStatus === true && window.__TAURI_INTERNALS__ === undefined) {
|
|
||||||
// (仅限浏览器环境)如果登录成功,获取当前用户的文件树
|
|
||||||
selectFolderRef.value.exposeTreeData();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const folderTreeRef = ref(null)
|
const folderTreeRef = ref(null)
|
||||||
// <SelectFolder> 返回的文件树数据,增加自定义数据,便于传给 <FolderTree> 进行渲染
|
// <SelectFolder> 返回的文件树数据,增加自定义数据,便于传给 <FolderTree> 进行渲染
|
||||||
function appendDataForNode(nodeData) {
|
function appendDataForNode(nodeData) {
|
||||||
|
|||||||
@ -1,475 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Split v-model="split">
|
|
||||||
<template #left>
|
|
||||||
<MdEditor ref="editorRef" v-model="editorState.text" :id="editorState.id" :theme="editorState.theme"
|
|
||||||
:previewTheme="editorState.previewTheme" :style="{ height: editorState.height }"
|
|
||||||
:toolbars="editorState.toolbars" :toolbarsExclude="editorState.toolbarsExclude" :sanitize="sanitize"
|
|
||||||
@onSave="onSave" @onHtmlChanged="getPreviewedHTML" @onUploadImg="onUploadImg">
|
|
||||||
<template #defToolbars>
|
|
||||||
<NormalToolbar :title="leftSidebarState == 'open' ? '隐藏文件树' : '显示文件树'" @onClick="toggleLeftSideBar">
|
|
||||||
<template #trigger>
|
|
||||||
<Icon v-if="leftSidebarState == 'open'" type="ios-arrow-dropleft" size="22" />
|
|
||||||
<Icon v-else type="ios-arrow-dropright" size="22" />
|
|
||||||
</template>
|
|
||||||
</NormalToolbar>
|
|
||||||
<NormalToolbar :title="split == 1 ? '显示目录' : '隐藏目录'" @onClick="toggleCatalog">
|
|
||||||
<template #trigger>
|
|
||||||
<Icon :class="split == 1 ? '' : 'md-editor-toolbar-active'" type="ios-list-box-outline"
|
|
||||||
size="22" />
|
|
||||||
</template>
|
|
||||||
</NormalToolbar>
|
|
||||||
<ExportPDF :modelValue="editorState.text" :customize="customizePDF"
|
|
||||||
@onProgress="handleExportProgress" />
|
|
||||||
<NormalToolbar title="同步到云端" @onClick="uploadCloud">
|
|
||||||
<template #trigger>
|
|
||||||
<Icon class="md-editor-icon" type="md-cloud-upload" size="22" />
|
|
||||||
</template>
|
|
||||||
</NormalToolbar>
|
|
||||||
<ThemeSwitch v-model="editorState.theme" />
|
|
||||||
<PreviewThemeSwitch v-model="editorState.previewTheme" />
|
|
||||||
</template>
|
|
||||||
</MdEditor>
|
|
||||||
</template>
|
|
||||||
<template #trigger>
|
|
||||||
<div class="split-trigger"></div>
|
|
||||||
</template>
|
|
||||||
<template #right>
|
|
||||||
<div class="split-right md-catalog">
|
|
||||||
<MdCatalog :class="hiddenSplitRight" :editorId="editorState.id" :theme="editorState.theme" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Split>
|
|
||||||
<UploadImageConfig v-model:visible="uploadImgForm.showFormModal" v-model:uploadImgAPI="uploadImgForm.uploadImgAPI"
|
|
||||||
v-model:uploadImgField="uploadImgForm.uploadImgField" v-model:otherField="uploadImgForm.otherField">
|
|
||||||
</UploadImageConfig>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import 'md-editor-v3/lib/style.css';
|
|
||||||
import '@vavt/v3-extension/lib/asset/PreviewThemeSwitch.css';
|
|
||||||
import '@vavt/v3-extension/lib/asset/ExportPDF.css';
|
|
||||||
import UploadImageConfig from './UI/UploadImageConfig.vue';
|
|
||||||
import scriptjs from 'scriptjs'
|
|
||||||
import { config, MdEditor, MdCatalog, NormalToolbar, XSSPlugin } from 'md-editor-v3';
|
|
||||||
import { ThemeSwitch, PreviewThemeSwitch, ExportPDF } from '@vavt/v3-extension';
|
|
||||||
import { lineNumbers } from '@codemirror/view';
|
|
||||||
import LinkAttr from 'markdown-it-link-attributes';
|
|
||||||
import { ref, reactive, watch, nextTick, onMounted } from "vue";
|
|
||||||
import { Message, Modal } from 'view-ui-plus';
|
|
||||||
import { encode as plantumlEncoder } from 'plantuml-encoder';
|
|
||||||
import { SubmitArticle, UpdateArticle } from '/src/utils/userHandler';
|
|
||||||
|
|
||||||
// fetchScript 用于将其他 JS 脚本加载进来
|
|
||||||
const fetchScript = (url) => new Promise((resolve) => scriptjs(url, () => resolve()));
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
markdownCode: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
leftSidebarState: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: 'open'
|
|
||||||
},
|
|
||||||
save: {
|
|
||||||
type: Function,
|
|
||||||
required: false,
|
|
||||||
default: (markdownCode) => {
|
|
||||||
console.log("save markdown code: " + markdownCode);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits([
|
|
||||||
'update:markdownCode',
|
|
||||||
'update:leftSidebarState',
|
|
||||||
'exportPDF',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 定义布局,主要用于在右边显示文章目录
|
|
||||||
const split = ref(1);
|
|
||||||
const defaultCatalogSplit = 0.2; // 默认目录和编辑器的比例为 8:2
|
|
||||||
const hiddenSplitRight = split.value == 1 ? ref('hidden') : ref('');
|
|
||||||
watch(split, (newSplit) => {
|
|
||||||
if (newSplit > 0.95) {
|
|
||||||
split.value = 1
|
|
||||||
if (hiddenSplitRight.value == '') {
|
|
||||||
hiddenSplitRight.value = 'hidden'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (hiddenSplitRight.value == 'hidden') {
|
|
||||||
hiddenSplitRight.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 加载 Markdown 编辑器
|
|
||||||
const editorRef = ref(null);
|
|
||||||
const editorState = reactive({
|
|
||||||
id: 'markdown-editor',
|
|
||||||
theme: 'light',
|
|
||||||
previewTheme: 'default',
|
|
||||||
height: '100vh',
|
|
||||||
text: props.markdownCode,
|
|
||||||
toolbars: [
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
'-',
|
|
||||||
'bold',
|
|
||||||
'underline',
|
|
||||||
'italic',
|
|
||||||
'-',
|
|
||||||
'title',
|
|
||||||
'strikeThrough',
|
|
||||||
'sub',
|
|
||||||
'sup',
|
|
||||||
'quote',
|
|
||||||
'unorderedList',
|
|
||||||
'orderedList',
|
|
||||||
'task',
|
|
||||||
'-',
|
|
||||||
'codeRow',
|
|
||||||
'code',
|
|
||||||
'link',
|
|
||||||
'image',
|
|
||||||
'table',
|
|
||||||
'mermaid',
|
|
||||||
'katex',
|
|
||||||
'-',
|
|
||||||
'revoke',
|
|
||||||
'next',
|
|
||||||
'save',
|
|
||||||
2,
|
|
||||||
3,
|
|
||||||
'=',
|
|
||||||
'prettier',
|
|
||||||
'pageFullscreen',
|
|
||||||
'fullscreen',
|
|
||||||
'preview',
|
|
||||||
'previewOnly',
|
|
||||||
'htmlPreview',
|
|
||||||
'catalog',
|
|
||||||
'github',
|
|
||||||
4,
|
|
||||||
5,
|
|
||||||
],
|
|
||||||
toolbarsExclude: ['fullscreen', 'github'], // 工具栏排除全屏和 github 等
|
|
||||||
pdfIns: null, // 用于存储 PDF 导出时候的 jsPDF 实例
|
|
||||||
});
|
|
||||||
// 监听 markdownCode(由父组件更新) 和 text(本组件更新)以实现双向更新
|
|
||||||
watch(() => props.markdownCode, (newCode) => {
|
|
||||||
// 当外部传入的 markdownCode 变化时候,更新 text 值
|
|
||||||
editorState.text = newCode;
|
|
||||||
})
|
|
||||||
watch(() => editorState.text, (newCode) => {
|
|
||||||
// 当 text 值变化时,告诉父组件
|
|
||||||
emit('update:markdownCode', newCode);
|
|
||||||
})
|
|
||||||
|
|
||||||
config({
|
|
||||||
codeMirrorExtensions(_theme, extensions) {
|
|
||||||
// 编辑器显示输入框的行号
|
|
||||||
return [...extensions, lineNumbers()];
|
|
||||||
},
|
|
||||||
markdownItPlugins(plugins) {
|
|
||||||
return [
|
|
||||||
...plugins,
|
|
||||||
{
|
|
||||||
type: 'xss',
|
|
||||||
plugin: XSSPlugin,
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'linkAttr',
|
|
||||||
plugin: LinkAttr,
|
|
||||||
options: {
|
|
||||||
matcher(href) {
|
|
||||||
// 如果使用了 markdown-it-anchor
|
|
||||||
// 应该忽略标题头部的锚点链接
|
|
||||||
return !href.startsWith('#');
|
|
||||||
},
|
|
||||||
attrs: {
|
|
||||||
target: '_blank',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleLeftSideBar = () => {
|
|
||||||
if (props.leftSidebarState == 'open') {
|
|
||||||
emit('update:leftSidebarState', 'close');
|
|
||||||
} else {
|
|
||||||
emit('update:leftSidebarState', 'open');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleCatalog = () => {
|
|
||||||
if (split.value < 1) {
|
|
||||||
split.value = 1;
|
|
||||||
} else {
|
|
||||||
split.value = 1 - defaultCatalogSplit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitize = (html) => {
|
|
||||||
// 防 XSS 攻击。使用 sanitize-html 处理不安全的 html 内容
|
|
||||||
// !!! 但是会导致目录跳转失败,因为 <h> 标签的部分属性被剔除掉了
|
|
||||||
// html = sanitizeHtml(html);
|
|
||||||
|
|
||||||
// PlantUML 渲染核心逻辑
|
|
||||||
html = html.replace(/@startuml([\s\S]*?)@enduml/g, (_, umlCode) => {
|
|
||||||
// 参数 umlCode 不包含 @startuml 和 @enduml
|
|
||||||
const sourceCode = `@startuml${umlCode}@enduml`;
|
|
||||||
const lines = umlCode.split('\n');
|
|
||||||
if (lines.length == 0) {
|
|
||||||
return sourceCode;
|
|
||||||
}
|
|
||||||
// 如果 @startuml 后接的不是 <br> 或者 </p>,可能是代码块中,不作解析
|
|
||||||
if (lines[0] !== '<br>' && lines[0] !== '</p>') {
|
|
||||||
return sourceCode;
|
|
||||||
}
|
|
||||||
const lastLine = lines[lines.length - 1];
|
|
||||||
// @enduml 前面要么是换行符(由于已切割 \n 所以会是空字符串)
|
|
||||||
// 要么是类似 <p data-line="7"> 的标签(前面有空行的情况)
|
|
||||||
// 如果都不是,则说明尾行不以 @enduml 开头,不作解析
|
|
||||||
if (lastLine.length > 0) {
|
|
||||||
// 既不是换行符也不是 <p data-line="7"> 标签,不作解析
|
|
||||||
if (!/<p\s+[^>]*>$/i.test(lastLine)) {
|
|
||||||
return sourceCode;
|
|
||||||
}
|
|
||||||
// 存在一种情况,@enduml 前面带 > 符号,即 >@enduml。这种情况:
|
|
||||||
// 尾行是 <p data-line="7"> 标签,上一行是 <blockquote data-line="7">,同样不作解析
|
|
||||||
if (lines.length >= 2 && /<blockquote\s+[^>]*>$/i.test(lines[lines.length - 2])) {
|
|
||||||
return sourceCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 去除换行作用的首行(首行是在 @startuml 后的换行符)
|
|
||||||
lines.shift();
|
|
||||||
// 去除换行作用的尾行(尾行是在 @enduml 前面的换行符)
|
|
||||||
lines.pop();
|
|
||||||
|
|
||||||
const removeLineBreak = /(.*?)(?=<br>|<\/p>)/i;
|
|
||||||
const extractedLines = lines.map(line => {
|
|
||||||
// 提取类似于 <p data-line="2">text<br> 中的文本
|
|
||||||
line = line.trim().replace(/<\w+\s+[^>]*>(.*?)<[^>]*>/i, '$1');
|
|
||||||
// 去除换行符
|
|
||||||
let match = line.match(removeLineBreak);
|
|
||||||
line = match ? match[1].trim() : line;
|
|
||||||
// 特殊字符串处理,解码 HTML 实体。比如 > 反转义为 >
|
|
||||||
const decodedText = decodeHTMLEntities(line);
|
|
||||||
return decodedText;
|
|
||||||
});
|
|
||||||
extractedLines.unshift('@startuml');
|
|
||||||
extractedLines.push('@enduml');
|
|
||||||
const plantUMLCode = extractedLines.join('\n');
|
|
||||||
console.log(plantUMLCode);
|
|
||||||
|
|
||||||
// 生成 PlantUML 编码字符串
|
|
||||||
const encoded = plantumlEncoder(plantUMLCode);
|
|
||||||
return `<img class="plantuml-diagram"
|
|
||||||
src="https://www.plantuml.com/plantuml/svg/${encoded}"
|
|
||||||
alt="PlantUML Diagram">`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return html;
|
|
||||||
};
|
|
||||||
|
|
||||||
const decodeHTMLEntities = (text) => {
|
|
||||||
const textArea = document.createElement('textarea');
|
|
||||||
textArea.innerHTML = text;
|
|
||||||
return textArea.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSave = (v, h) => {
|
|
||||||
// 调用 save 方法保存代码(参数 v 为 markdown code)
|
|
||||||
props.save(v);
|
|
||||||
|
|
||||||
// 使用 h 可以在保存时直接获取 html
|
|
||||||
// h.then((html) => {
|
|
||||||
// console.log(html);
|
|
||||||
// });
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPreviewedHTML = (html) => {
|
|
||||||
console.log(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
const customizePDF = (ins) => {
|
|
||||||
// 将 jsPDF 的实例记录到 editorState.pdfIns 便于后面获取
|
|
||||||
editorState.pdfIns = ins;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExportProgress = (progress) => {
|
|
||||||
// console.log(`Export progress: ${progress.ratio * 100}%`);
|
|
||||||
// 当进度到达 100% 时,触发保存事件。
|
|
||||||
if (progress.ratio == 1) {
|
|
||||||
// 对于浏览器环境,ExportPDF 插件中已实现调用保存文件的窗口
|
|
||||||
if (window.__TAURI_INTERNALS__ === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 非浏览器环境(tauri 环境)需要主动调用系统 API,调出对话框并保存文件
|
|
||||||
if (editorState.pdfIns !== null) {
|
|
||||||
emit('exportPDF', editorState.pdfIns.output('arraybuffer'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadCloud = () => {
|
|
||||||
// 找到 markdown 代码第一行,第一行前面的空行都剔除掉
|
|
||||||
const markdownLines = editorState.text.split('\n');
|
|
||||||
let firstLine = 0;
|
|
||||||
for (let index = 0; index < markdownLines.length; index++) {
|
|
||||||
if (markdownLines[index].trim().length > 0) {
|
|
||||||
firstLine = index;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let index = 0; index < firstLine; index++) {
|
|
||||||
markdownLines.shift();
|
|
||||||
}
|
|
||||||
const firstLineText = markdownLines[0].trim();
|
|
||||||
if (!firstLineText.startsWith('# ')) {
|
|
||||||
Message.warning('第一行请以 “# 文章标题” 作为开头');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从第一行里提取文章标题和文章 ID
|
|
||||||
let title = firstLineText.slice(2).trim();
|
|
||||||
let articleID = '';
|
|
||||||
const match = title.match(/^\[(.*?)\]\((.*?)\)$/);
|
|
||||||
if (match) {
|
|
||||||
title = match[1].trim(); // "标题"
|
|
||||||
let url = match[2].trim(); // "链接,例如 https://example.com/blog/1"
|
|
||||||
const matchID = url.match(/(\d+)$/);
|
|
||||||
articleID = matchID ? matchID[1] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果存在文章 ID,则更新该文章;否则发布该文章,并在文章 ID 中加入链接
|
|
||||||
if (articleID.length > 0 && !isNaN(Number(articleID, 10))) {
|
|
||||||
editorState.text = markdownLines.join('\n');
|
|
||||||
// 直接调用更新文章的接口
|
|
||||||
UpdateArticle(parseInt(articleID, 10), title, editorState.text)
|
|
||||||
.then((data) => {
|
|
||||||
Modal.remove();
|
|
||||||
Message.success('同步成功');
|
|
||||||
if (window.__TAURI_INTERNALS__ !== undefined) {
|
|
||||||
// 浏览器环境 onSave 就是更新保存文章,不用多余调用
|
|
||||||
onSave(editorState.text);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
Message.error(`同步异常: ${error}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 询问是否要用 title 作为标题发布文章
|
|
||||||
Modal.confirm({
|
|
||||||
title: '发布前确认',
|
|
||||||
content: `<p>确认使用《${title}》作为文章标题将其同步到云端。<br/>点击确认之后,会同步文章,并修改第一行嵌入文章 ID,最后将当前内容保存操作到本地。</p>`,
|
|
||||||
loading: true,
|
|
||||||
onOk: () => {
|
|
||||||
SubmitArticle(title, markdownLines.join('\n'))
|
|
||||||
.then((data) => {
|
|
||||||
articleID = data.id;
|
|
||||||
markdownLines[0] = '# [' + title + '](../' + articleID + ')';
|
|
||||||
editorState.text = markdownLines.join('\n');
|
|
||||||
Modal.remove();
|
|
||||||
Message.success('同步成功');
|
|
||||||
if (window.__TAURI_INTERNALS__ !== undefined) {
|
|
||||||
// 浏览器环境 onSave 就是更新保存文章,不用多余调用
|
|
||||||
onSave(editorState.text);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
Message.error(`同步异常: ${error}`);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadImgForm = reactive({
|
|
||||||
// 是否显示表单对话框
|
|
||||||
showFormModal: false,
|
|
||||||
// 上传图片的后端 API 接口
|
|
||||||
uploadImgAPI: localStorage.getItem('imageUploadURLOfEditor') || '',
|
|
||||||
// 上传图片时,FormData 的文件字段
|
|
||||||
uploadImgField: localStorage.getItem('imageUploadFieldOfEditor') || '',
|
|
||||||
// 上传图片时,FormData 里附带的其他字段和数值. example: [{ key: 'token', value: 'uuid' }]
|
|
||||||
otherField: JSON.parse(localStorage.getItem('imageUploadOtherFieldOfEditor')) || [],
|
|
||||||
});
|
|
||||||
watch(() => uploadImgForm.uploadImgAPI, (newAPI) => {
|
|
||||||
localStorage.setItem('imageUploadURLOfEditor', newAPI);
|
|
||||||
})
|
|
||||||
watch(() => uploadImgForm.uploadImgField, (newField) => {
|
|
||||||
localStorage.setItem('imageUploadFieldOfEditor', newField);
|
|
||||||
})
|
|
||||||
watch(() => uploadImgForm.otherField, (newData) => {
|
|
||||||
localStorage.setItem('imageUploadOtherFieldOfEditor', JSON.stringify(newData));
|
|
||||||
})
|
|
||||||
|
|
||||||
const onUploadImg = async (files, callback) => {
|
|
||||||
if (uploadImgForm.uploadImgAPI.trim().length == 0 || uploadImgForm.uploadImgField.trim().length == 0) {
|
|
||||||
Message.warning("请先配置图片上传的后端接口,以及对应的字段名称");
|
|
||||||
uploadImgForm.showFormModal = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await Promise.all(
|
|
||||||
files.map((file) => {
|
|
||||||
return new Promise((rev, rej) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append(uploadImgForm.uploadImgField.trim(), file);
|
|
||||||
uploadImgForm.otherField.forEach(field => {
|
|
||||||
formData.append(field.key.trim(), field.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
fetch(uploadImgForm.uploadImgAPI.trim(), {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
}).then(response => {
|
|
||||||
if (!response.ok) throw new Error(`HTTP 错误: ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
rev(data);
|
|
||||||
}).catch(error => {
|
|
||||||
rej(error);
|
|
||||||
Message.error(`上传失败: ${error}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
callback(res.map((item) => item.url));
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
// 加载 jQuery 脚本以便能够使用 jQuery 语法
|
|
||||||
await fetchScript('/static/jquery/jquery.min.js');
|
|
||||||
// 使用 nextTick 确保页面组件已完成挂载和渲染
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
// (这个做法并不规范)找到工具栏中「上传图片」的下拉框,新增一个选项,支持更新图片上传的配置
|
|
||||||
let imageIcon = $('div.md-editor-toolbar-item[title="图片"]').first();
|
|
||||||
let dropdownOverlay = imageIcon.next().children().first();
|
|
||||||
let ulOfDropdownOverlay = dropdownOverlay.children().first();
|
|
||||||
let updateLi = $('<li class="md-editor-menu-item md-editor-menu-item-image" role="menuitem" tabindex="0">更新配置</li>');
|
|
||||||
updateLi.on('click', () => uploadImgForm.showFormModal = true);
|
|
||||||
updateLi.appendTo(ulOfDropdownOverlay);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 对父组件暴露数据或方法
|
|
||||||
defineExpose({})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.md-catalog {
|
|
||||||
height: 100vh;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -3,7 +3,7 @@
|
|||||||
<!-- rewrite Markdown CSS(Cmd Markdown Style) from https://github.com/wakaryry/editorMd -->
|
<!-- rewrite Markdown CSS(Cmd Markdown Style) from https://github.com/wakaryry/editorMd -->
|
||||||
<link rel="stylesheet" href="/static/CmdMarkdown/custom.css">
|
<link rel="stylesheet" href="/static/CmdMarkdown/custom.css">
|
||||||
|
|
||||||
<div :id="editorID"></div>
|
<div id="markdown-editor"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -15,11 +15,6 @@ let editorClient = defaultEditorValue;
|
|||||||
export default {
|
export default {
|
||||||
name: 'MarkdownEditor',
|
name: 'MarkdownEditor',
|
||||||
props: {
|
props: {
|
||||||
editorID: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: 'markdown-editor'
|
|
||||||
},
|
|
||||||
// 加载该组件时,是否自动执行 initEditor
|
// 加载该组件时,是否自动执行 initEditor
|
||||||
// 如果为 false,则将初始化的时机和主动权交给父组件
|
// 如果为 false,则将初始化的时机和主动权交给父组件
|
||||||
autoInit: {
|
autoInit: {
|
||||||
@ -111,7 +106,6 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loadingCompleted: false,
|
loadingCompleted: false,
|
||||||
editormdPreview: null,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@ -139,7 +133,7 @@ export default {
|
|||||||
// 加载完成以上 JS 资源之后,再加载编辑器
|
// 加载完成以上 JS 资源之后,再加载编辑器
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
const vm = this
|
const vm = this
|
||||||
const editor = window.editormd(vm.editorID, {
|
const editor = window.editormd('markdown-editor', {
|
||||||
// 初始化内容
|
// 初始化内容
|
||||||
markdown: vm.markdownCode,
|
markdown: vm.markdownCode,
|
||||||
// 定义宽度和高度
|
// 定义宽度和高度
|
||||||
@ -196,7 +190,6 @@ export default {
|
|||||||
// 将 editor 变量赋予 editorClient,便于后续调用 API
|
// 将 editor 变量赋予 editorClient,便于后续调用 API
|
||||||
editorClient = this
|
editorClient = this
|
||||||
vm.loadingCompleted = true
|
vm.loadingCompleted = true
|
||||||
vm.editormdPreview = $('#' + vm.editorID + ' .editormd-preview-container')[0]
|
|
||||||
// 执行父组件传递的 callback 方法
|
// 执行父组件传递的 callback 方法
|
||||||
vm.onload()
|
vm.onload()
|
||||||
|
|
||||||
@ -256,105 +249,8 @@ export default {
|
|||||||
}
|
}
|
||||||
return editorClient.getPreviewedHTML()
|
return editorClient.getPreviewedHTML()
|
||||||
},
|
},
|
||||||
getTableOfContents() {
|
|
||||||
return generateTableOfContents(this.editormdPreview);
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取一篇文档的章节标题的目录
|
|
||||||
*
|
|
||||||
* 入参示例:
|
|
||||||
* <div>
|
|
||||||
* <h1>1</h1>
|
|
||||||
* <h2>1.1</h2>
|
|
||||||
* <h1>2</h1>
|
|
||||||
* <h2>2.1</h2>
|
|
||||||
* </div>
|
|
||||||
*
|
|
||||||
* 输出示例:
|
|
||||||
* [{
|
|
||||||
* "name": "1",
|
|
||||||
* "nodes": [{
|
|
||||||
* "name": "1.1",
|
|
||||||
* "nodes": []
|
|
||||||
* }]
|
|
||||||
* },
|
|
||||||
* {
|
|
||||||
* "name": "2",
|
|
||||||
* "nodes": [{
|
|
||||||
* "name": "2.1",
|
|
||||||
* "nodes": []
|
|
||||||
* }]
|
|
||||||
* }]
|
|
||||||
*
|
|
||||||
* @param {Element} element 文档的 document 对象
|
|
||||||
* @returns {Array} tocObject 目录列表(Table of Contents)
|
|
||||||
*/
|
|
||||||
function generateTableOfContents(element) {
|
|
||||||
// 该变量用于存储文章目录节点
|
|
||||||
let tableOfContents = {
|
|
||||||
name: "",
|
|
||||||
parent: null,
|
|
||||||
nodes: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 遍历得到所有直接子元素中的 <h> 标签
|
|
||||||
const hTags = element.querySelectorAll(":scope > h1, :scope > h2, :scope > h3, :scope > h4, :scope > h5, :scope > h6");
|
|
||||||
|
|
||||||
let lastLevel = 0;
|
|
||||||
let lastNode = tableOfContents;
|
|
||||||
// 遍历文档内所有 H 标签
|
|
||||||
hTags.forEach(hTag => {
|
|
||||||
// currentLevel 表示 H 标签后的数字,比如 H1 标签,那么 currentLevel 为 1
|
|
||||||
let currentLevel = hTag.tagName[1];
|
|
||||||
// headTitle 则是 H 标签的文本,将用于在目录中显示
|
|
||||||
let headTitle = hTag.innerText;
|
|
||||||
lastNode = generateNodeOfTOC(lastNode, lastLevel, currentLevel, headTitle);
|
|
||||||
lastLevel = currentLevel;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 删除 node 节点中的 parent 字段,避免循环嵌套(circular structure)问题
|
|
||||||
deleteParentAssociation(tableOfContents);
|
|
||||||
return tableOfContents.nodes;
|
|
||||||
}
|
|
||||||
function generateNodeOfTOC(lastNode, lastLevel, currentLevel, headTitle) {
|
|
||||||
// 需要找到刚好比当前节点大一级的 lastNode
|
|
||||||
if (currentLevel > lastLevel) {
|
|
||||||
// 如果当前 H 标题的级别小于上一个 H 标题(数字越小,级别越大)
|
|
||||||
let diff = currentLevel - lastLevel;
|
|
||||||
for (let i = 0; i < diff - 1; i++) {
|
|
||||||
let node = {
|
|
||||||
name: "",
|
|
||||||
nodes: [],
|
|
||||||
parent: lastNode,
|
|
||||||
}
|
|
||||||
lastNode.nodes.push(node);
|
|
||||||
lastNode = node;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果当前 H 标题的级别大于等于上一个 H 标题(数字越小,级别越大)
|
|
||||||
let diff = lastLevel - currentLevel;
|
|
||||||
for (let i = 0; i <= diff; i++) {
|
|
||||||
lastNode = lastNode.parent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentNode = {
|
|
||||||
name: headTitle,
|
|
||||||
nodes: [],
|
|
||||||
parent: lastNode,
|
|
||||||
}
|
|
||||||
lastNode.nodes.push(currentNode);
|
|
||||||
return currentNode;
|
|
||||||
}
|
|
||||||
function deleteParentAssociation(node) {
|
|
||||||
delete node.parent;
|
|
||||||
node.nodes.forEach(n => {
|
|
||||||
deleteParentAssociation(n);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
@ -35,8 +35,6 @@
|
|||||||
import { join } from '@tauri-apps/api/path';
|
import { join } from '@tauri-apps/api/path';
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { readDir } from '@tauri-apps/plugin-fs';
|
import { readDir } from '@tauri-apps/plugin-fs';
|
||||||
import { GetUserArticle } from '/src/utils/userHandler';
|
|
||||||
import { Message } from 'view-ui-plus';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
@ -61,39 +59,12 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async exposeTreeData(folderPath) {
|
async exposeTreeData(folderPath) {
|
||||||
if (window.__TAURI_INTERNALS__ === undefined) {
|
|
||||||
GetUserArticle().then((data) => {
|
|
||||||
folderPath = '/Users/' + data.username;
|
|
||||||
const folderTreeData = {
|
|
||||||
"path": folderPath,
|
|
||||||
"name": data.username,
|
|
||||||
"nodes": [],
|
|
||||||
"directory": true,
|
|
||||||
};
|
|
||||||
data.articles.forEach((article) => {
|
|
||||||
folderTreeData.nodes.push({
|
|
||||||
"path": folderPath + '/' + article.article_id,
|
|
||||||
"name": article.article_id + '_' + article.title,
|
|
||||||
"suffix": "md",
|
|
||||||
"directory": false,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
this.$emit('folder-selected', folderTreeData);
|
|
||||||
}).catch((error) => {
|
|
||||||
Message.error(`调用异常: ${error}`);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (folderPath != null && folderPath.length > 0) {
|
if (folderPath != null && folderPath.length > 0) {
|
||||||
const folderTreeData = await this.readDirRecursively(folderPath);
|
const folderTreeData = await this.readDirRecursively(folderPath);
|
||||||
this.$emit('folder-selected', folderTreeData);
|
this.$emit('folder-selected', folderTreeData);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async selectFolder() {
|
async selectFolder() {
|
||||||
if (window.__TAURI_INTERNALS__ === undefined) {
|
|
||||||
// 非 Tauri 环境下,会默认加载用户数据列表,不支持选择目录
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const folderPath = await open(
|
const folderPath = await open(
|
||||||
{
|
{
|
||||||
filters: [{ name: 'All Files', extensions: ['.'] }],
|
filters: [{ name: 'All Files', extensions: ['.'] }],
|
||||||
@ -1,36 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Anchor v-if="nodes !== null" :affix="false">
|
|
||||||
<AnchorLink v-for="node in nodes" :key="node.name" :href="'#' + node.name" :title="node.name">
|
|
||||||
<AnchorList :nodes="node.nodes"></AnchorList>
|
|
||||||
</AnchorLink>
|
|
||||||
</Anchor>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
/**
|
|
||||||
* 输出示例:
|
|
||||||
* [{
|
|
||||||
* "name": "1",
|
|
||||||
* "nodes": [{
|
|
||||||
* "name": "1.1",
|
|
||||||
* "nodes": []
|
|
||||||
* }]
|
|
||||||
* },
|
|
||||||
* {
|
|
||||||
* "name": "2",
|
|
||||||
* "nodes": [{
|
|
||||||
* "name": "2.1",
|
|
||||||
* "nodes": []
|
|
||||||
* }]
|
|
||||||
* }]
|
|
||||||
*/
|
|
||||||
nodes: {
|
|
||||||
type: Array,
|
|
||||||
required: false,
|
|
||||||
default: () => {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Modal v-model="showFormModal" :footer-hide="true">
|
|
||||||
<template #header>
|
|
||||||
<Space>
|
|
||||||
<span>上传图片配置(configuration for uploading image)</span>
|
|
||||||
<Poptip placement="bottom" width="400" word-wrap>
|
|
||||||
<Button type="info" shape="circle" size="small" icon="md-help"></Button>
|
|
||||||
<template #content>
|
|
||||||
<Typography>
|
|
||||||
<blockquote>
|
|
||||||
假设已经拥有一个图片上传的 HTTP 接口。该接口:
|
|
||||||
<ul>
|
|
||||||
<li>接收<code>Content-Type: multipart/form-data</code>数据</li>
|
|
||||||
<li>能够通过<code>response.url</code>得到上传成功的图片 URL</li>
|
|
||||||
</ul>
|
|
||||||
</blockquote>
|
|
||||||
<Title :level="6">API(必填)</Title>
|
|
||||||
<Paragraph>
|
|
||||||
后端接口的 URL。示例 https://api.example.com/upload
|
|
||||||
</Paragraph>
|
|
||||||
<Title :level="6">Field(必填)</Title>
|
|
||||||
<Paragraph>
|
|
||||||
上传图片时,该图片文件在 form-data 中的字段名
|
|
||||||
</Paragraph>
|
|
||||||
<Title :level="6">More Fields(可选)</Title>
|
|
||||||
<Paragraph>
|
|
||||||
其他附带字段名,比如 token,可用于辅助鉴权
|
|
||||||
</Paragraph>
|
|
||||||
</Typography>
|
|
||||||
</template>
|
|
||||||
</Poptip>
|
|
||||||
</Space>
|
|
||||||
</template>
|
|
||||||
<Form ref="uploadImgFormRef" :model="configForm" :label-width="80" style="width: 300px">
|
|
||||||
<FormItem label="API" prop="uploadImgAPI" required>
|
|
||||||
<Tooltip content="上传图片的后端 API 接口" placement="top" max-width="300">
|
|
||||||
<Input type="text" v-model="configForm.uploadImgAPI"
|
|
||||||
placeholder="example: https://api.example.com/upload" style="width: 290px;"></Input>
|
|
||||||
</Tooltip>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="Field" prop="uploadImgField" required>
|
|
||||||
<Tooltip content="上传图片时,该图片文件在 form-data 中的字段名" placement="top" max-width="300">
|
|
||||||
<Input type="text" v-model="configForm.uploadImgField"
|
|
||||||
placeholder="File Field. example: image or file ..." style="width: 290px;"></Input>
|
|
||||||
</Tooltip>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem v-for="(field, index) in configForm.otherField" :key="index" :label="'Field ' + (index + 1)"
|
|
||||||
:prop="'otherField.' + index + '.key'"
|
|
||||||
:rules="{ required: true, message: 'Field Key can not be empty', trigger: 'blur' }">
|
|
||||||
<Space>
|
|
||||||
<Tooltip content="其他附带字段名,比如 token,可用于辅助鉴权" placement="top" max-width="300">
|
|
||||||
<Input type="text" v-model="field.key" placeholder="Field Key" style="width: 80px;"></Input>
|
|
||||||
</Tooltip>
|
|
||||||
<Input type="text" v-model="field.value" placeholder="Field Value" style="width: 200px;"></Input>
|
|
||||||
<Button @click="removeField(index)">Delete</Button>
|
|
||||||
</Space>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem>
|
|
||||||
<Button type="dashed" long @click="addField()" icon="md-add">Add More Field</Button>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem>
|
|
||||||
<Button type="primary" @click="handleSubmit()">Submit</Button>
|
|
||||||
<Button @click="handleReset()" style="margin-left: 8px">Reset</Button>
|
|
||||||
</FormItem>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, watch, useTemplateRef } from "vue";
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
visible: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
uploadImgAPI: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
uploadImgField: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
// 示例 [{ key: 'token', value: 'uuid' }]
|
|
||||||
otherField: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
default: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits([
|
|
||||||
'update:visible',
|
|
||||||
'update:uploadImgAPI',
|
|
||||||
'update:uploadImgField',
|
|
||||||
'update:otherField',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const uploadImgFormRef = useTemplateRef('uploadImgFormRef');
|
|
||||||
|
|
||||||
// 是否弹出对话框。要求双向绑定
|
|
||||||
const showFormModal = ref(props.visible);
|
|
||||||
watch(() => props.visible, (visible) => {
|
|
||||||
showFormModal.value = visible;
|
|
||||||
})
|
|
||||||
watch(showFormModal, (visible) => {
|
|
||||||
emit('update:visible', visible);
|
|
||||||
})
|
|
||||||
|
|
||||||
const configForm = reactive({
|
|
||||||
// 上传图片的后端 API 接口
|
|
||||||
uploadImgAPI: props.uploadImgAPI,
|
|
||||||
// 上传图片时,FormData 的文件字段
|
|
||||||
uploadImgField: props.uploadImgField,
|
|
||||||
// 上传图片时,FormData 里附带的其他字段和数值
|
|
||||||
otherField: JSON.parse(JSON.stringify(props.otherField)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const addField = () => {
|
|
||||||
configForm.otherField.push({ key: '', value: '' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeField = (index) => {
|
|
||||||
configForm.otherField.splice(index, 1)
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
uploadImgFormRef.value.validate((valid) => {
|
|
||||||
if (valid) {
|
|
||||||
emit('update:uploadImgAPI', configForm.uploadImgAPI);
|
|
||||||
emit('update:uploadImgField', configForm.uploadImgField);
|
|
||||||
emit('update:otherField', JSON.parse(JSON.stringify(configForm.otherField)));
|
|
||||||
showFormModal.value = false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
uploadImgFormRef.value.resetFields();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@ -1,157 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Modal v-model="showFormModal" :footer-hide="true" class="login-modal">
|
|
||||||
<div style="padding-top: 30px;"></div>
|
|
||||||
<Login v-if="signInOrSignUp" @on-submit="handleSignIn">
|
|
||||||
<UserName name="username" />
|
|
||||||
<Password name="password" />
|
|
||||||
<div class="auto-login">
|
|
||||||
<Checkbox v-model="autoLogin" size="large">记住登录状态</Checkbox>
|
|
||||||
<!-- <a>忘记密码</a> -->
|
|
||||||
</div>
|
|
||||||
<Submit :loading="submitLoading" />
|
|
||||||
<Space split>还未拥有账号?<Button type="text" @click="signInOrSignUp = false">前往注册</Button></Space>
|
|
||||||
</Login>
|
|
||||||
<Login v-else @on-submit="handleSignUp">
|
|
||||||
<UserName name="username" />
|
|
||||||
<Email name="mail" />
|
|
||||||
<Password name="password" />
|
|
||||||
<Password name="passwordConfirm" placeholder="确认密码" />
|
|
||||||
<Submit :loading="submitLoading">注册</Submit>
|
|
||||||
<Space split>已经拥有账号?<Button type="text" @click="signInOrSignUp = true">前往登录</Button></Space>
|
|
||||||
</Login>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { SignIn, SignUp, SendSignUpEmail, CheckLoginStatus, Logout } from '/src/utils/userHandler';
|
|
||||||
import { ref, watch, onMounted } from "vue";
|
|
||||||
import { Message } from 'view-ui-plus';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
visible: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
loginStatus: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits([
|
|
||||||
'update:visible',
|
|
||||||
'update:loginStatus',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 是否弹出对话框。要求双向绑定
|
|
||||||
const showFormModal = ref(props.visible);
|
|
||||||
watch(() => props.visible, (visible) => {
|
|
||||||
showFormModal.value = visible;
|
|
||||||
})
|
|
||||||
watch(showFormModal, (visible) => {
|
|
||||||
emit('update:visible', visible);
|
|
||||||
})
|
|
||||||
|
|
||||||
const signInOrSignUp = ref(true);
|
|
||||||
const autoLogin = ref(false);
|
|
||||||
const submitLoading = ref(false);
|
|
||||||
|
|
||||||
const handleSignIn = (valid, { username, password }) => {
|
|
||||||
if (!valid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
submitLoading.value = true;
|
|
||||||
SignIn(username, password, autoLogin.value)
|
|
||||||
.then((data) => {
|
|
||||||
Message.success('登录成功');
|
|
||||||
// 将登录成功的状态通知父组件
|
|
||||||
emit('update:loginStatus', true);
|
|
||||||
// 停止 loading,关闭对话框
|
|
||||||
submitLoading.value = false;
|
|
||||||
showFormModal.value = false;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
submitLoading.value = false;
|
|
||||||
Message.error(`登录发生异常: ${error}`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignUp = (valid, { username, mail, password, passwordConfirm }) => {
|
|
||||||
if (!valid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (passwordConfirm !== password) {
|
|
||||||
Message.error('输入的密码不一致,请重新输入');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
submitLoading.value = true;
|
|
||||||
SignUp(username, mail, password)
|
|
||||||
.then((data) => {
|
|
||||||
// 注册后会返回一个 userID,用于发送激活邮件
|
|
||||||
SendSignUpEmail(data.id)
|
|
||||||
.then((data) => {
|
|
||||||
Message.success('请前往邮箱激活账号(没收到的话检查垃圾箱),激活后再回来登录账号');
|
|
||||||
// 发送邮件成功,(从注册界面)回到登录窗口
|
|
||||||
submitLoading.value = false;
|
|
||||||
signInOrSignUp.value = true;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
submitLoading.value = false;
|
|
||||||
Message.error(`发送激活邮件发生异常: ${error}`);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
submitLoading.value = false;
|
|
||||||
Message.error(`注册发生异常: ${error}`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkLoginStatus = async () => {
|
|
||||||
CheckLoginStatus()
|
|
||||||
.then((data) => {
|
|
||||||
emit('update:loginStatus', true);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
Message.error(`检查登录状态发生异常: ${error}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const logout = async () => {
|
|
||||||
Logout()
|
|
||||||
.then((data) => {
|
|
||||||
Message.info('登出成功');
|
|
||||||
emit('update:loginStatus', false);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
Message.error(`登出发生异常: ${error}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
logout,
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
// 检查是否已登录
|
|
||||||
checkLoginStatus();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.login-modal {
|
|
||||||
width: 400px;
|
|
||||||
margin: 0 auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auto-login {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auto-login a {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,268 +0,0 @@
|
|||||||
import md5 from 'js-md5';
|
|
||||||
|
|
||||||
// 由于是跨域登录,需要手动设置并携带 Cookie
|
|
||||||
const setPHPSessionToCookie = (sessionID) => {
|
|
||||||
if (sessionID) {
|
|
||||||
// 使用 SameSite=None 和 secure 属性,在 tauri 环境会设置失败
|
|
||||||
// document.cookie = `PHPSESSID=${sessionID}; max-age=604800; SameSite=None; secure; path=/`;
|
|
||||||
localStorage.setItem('PHPSESSID', sessionID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPHPSessionFromCookie = () => {
|
|
||||||
let PHPSESSID = localStorage.getItem('PHPSESSID');
|
|
||||||
// const cookies = document.cookie.split('; ');
|
|
||||||
// cookies.forEach(cookie => {
|
|
||||||
// const [key, value] = cookie.split('=');
|
|
||||||
// if (key === 'PHPSESSID') {
|
|
||||||
// PHPSESSID = decodeURIComponent(value); // 解码特殊字符(如空格、%20)
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
return PHPSESSID;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delPHPSessionFromCookie = () => {
|
|
||||||
// document.cookie = `PHPSESSID=; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
|
|
||||||
localStorage.removeItem('PHPSESSID');
|
|
||||||
}
|
|
||||||
|
|
||||||
const SignIn = (username, password, autoLogin) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
fetch('https://myafei.cn/php/ajax.php', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
'func': 'login',
|
|
||||||
'username': username,
|
|
||||||
'is_email': /^[0-9a-zA-Z_-]{1,100}$/.test(username) ? false : true,
|
|
||||||
'password': md5(password),
|
|
||||||
'isRemember': autoLogin,
|
|
||||||
}),
|
|
||||||
}).then(response => {
|
|
||||||
if (!response.ok) throw new Error(`HTTP 错误: ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if (data.status == 0) {
|
|
||||||
setPHPSessionToCookie(data.PHPSESSID);
|
|
||||||
resolve(data);
|
|
||||||
} else {
|
|
||||||
reject(new Error(data.error));
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const SignUp = (username, mail, password) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
fetch('https://myafei.cn/php/ajax.php', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
'func': 'register',
|
|
||||||
'username': username,
|
|
||||||
'email': mail,
|
|
||||||
'password': md5(password),
|
|
||||||
}),
|
|
||||||
}).then(response => {
|
|
||||||
if (!response.ok) throw new Error(`HTTP 错误: ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if (data.status == 0) {
|
|
||||||
resolve(data);
|
|
||||||
} else {
|
|
||||||
reject(new Error(data.error));
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const SendSignUpEmail = (userID) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
fetch('https://myafei.cn/php/ajax.php', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
'func': 'resend_email',
|
|
||||||
'id': userID,
|
|
||||||
'path': '/',
|
|
||||||
}),
|
|
||||||
}).then(response => {
|
|
||||||
if (!response.ok) throw new Error(`HTTP 错误: ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if (data.status == 0) {
|
|
||||||
resolve(data);
|
|
||||||
} else if (data.status == -100) {
|
|
||||||
reject(new Error('邮箱已被激活'));
|
|
||||||
} else {
|
|
||||||
reject(new Error(data.error));
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const CheckLoginStatus = () => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// 先从 cookie 中拿到 PHPSESSID
|
|
||||||
const PHPSESSID = getPHPSessionFromCookie();
|
|
||||||
fetch('https://myafei.cn/php/ajax.php?PHPSESSID=' + PHPSESSID, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
'func': 'is_login',
|
|
||||||
}),
|
|
||||||
}).then(response => {
|
|
||||||
if (!response.ok) throw new Error(`HTTP 错误: ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if (data.status == 0) {
|
|
||||||
resolve(data);
|
|
||||||
} else {
|
|
||||||
// 未登录不抛出异常
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const Logout = () => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const PHPSESSID = getPHPSessionFromCookie();
|
|
||||||
fetch('https://myafei.cn/php/ajax.php?PHPSESSID=' + PHPSESSID, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
'func': 'logout',
|
|
||||||
}),
|
|
||||||
}).then(response => {
|
|
||||||
if (!response.ok) throw new Error(`HTTP 错误: ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if (data.status == 0) {
|
|
||||||
delPHPSessionFromCookie();
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error(data.error));
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const SubmitArticle = (title, mdCode) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const PHPSESSID = getPHPSessionFromCookie();
|
|
||||||
fetch('https://myafei.cn/php/ajax.php?PHPSESSID=' + PHPSESSID, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
'func': 'submit_article',
|
|
||||||
'title': title,
|
|
||||||
'mdCode': mdCode,
|
|
||||||
}),
|
|
||||||
}).then(response => {
|
|
||||||
if (!response.ok) throw new Error(`HTTP 错误: ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if (data.status == 0) {
|
|
||||||
resolve(data);
|
|
||||||
} else {
|
|
||||||
reject(new Error(data.error));
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const UpdateArticle = (id, title, mdCode) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const PHPSESSID = getPHPSessionFromCookie();
|
|
||||||
let body = {
|
|
||||||
'func': 'update_article',
|
|
||||||
'id': id,
|
|
||||||
}
|
|
||||||
if (title !== null && title !== undefined) {
|
|
||||||
body.title = title;
|
|
||||||
}
|
|
||||||
if (mdCode !== null && mdCode !== undefined) {
|
|
||||||
body.mdCode = mdCode;
|
|
||||||
}
|
|
||||||
fetch('https://myafei.cn/php/ajax.php?PHPSESSID=' + PHPSESSID, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}).then(response => {
|
|
||||||
if (!response.ok) throw new Error(`HTTP 错误: ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if (data.status == 0) {
|
|
||||||
resolve(data);
|
|
||||||
} else {
|
|
||||||
reject(new Error(data.error));
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const GetSingleArticle = (id) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const PHPSESSID = getPHPSessionFromCookie();
|
|
||||||
fetch('https://myafei.cn/php/ajax.php?PHPSESSID=' + PHPSESSID, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
'func': 'init_submit',
|
|
||||||
'id': id,
|
|
||||||
}),
|
|
||||||
}).then(response => {
|
|
||||||
if (!response.ok) throw new Error(`HTTP 错误: ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if (data.status == 0) {
|
|
||||||
resolve(data);
|
|
||||||
} else {
|
|
||||||
reject(new Error(data.error));
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const GetUserArticle = (id, title, mdCode) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const PHPSESSID = getPHPSessionFromCookie();
|
|
||||||
fetch('https://myafei.cn/php/ajax.php?PHPSESSID=' + PHPSESSID, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
'func': 'get_articles',
|
|
||||||
}),
|
|
||||||
}).then(response => {
|
|
||||||
if (!response.ok) throw new Error(`HTTP 错误: ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if (data.status == 0) {
|
|
||||||
resolve(data);
|
|
||||||
} else {
|
|
||||||
// 未登录不抛出异常
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
SignIn,
|
|
||||||
SignUp,
|
|
||||||
SendSignUpEmail,
|
|
||||||
CheckLoginStatus,
|
|
||||||
Logout,
|
|
||||||
SubmitArticle,
|
|
||||||
UpdateArticle,
|
|
||||||
GetSingleArticle,
|
|
||||||
GetUserArticle,
|
|
||||||
};
|
|
||||||
Loading…
x
Reference in New Issue
Block a user