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 的宽度
|
||||
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`
|
||||
|
||||
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-fs": "^2.2.1",
|
||||
"@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",
|
||||
"view-ui-plus": "^1.3.19",
|
||||
"vue": "^3.5.13"
|
||||
|
||||
@ -10,9 +10,6 @@
|
||||
"opener:default",
|
||||
"fs:default",
|
||||
"dialog:default",
|
||||
{
|
||||
"identifier": "core:window:allow-destroy"
|
||||
},
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "markdown-editor",
|
||||
"version": "2.2.0",
|
||||
"version": "1.1.0",
|
||||
"identifier": "com.markdown-editor.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
305
src/App.vue
305
src/App.vue
@ -1,35 +1,35 @@
|
||||
<template>
|
||||
<Split v-model="split" min="5px">
|
||||
<template #left>
|
||||
<div class="split-left" :class="hiddenSplitLeft">
|
||||
<LeftSidebar v-model:rootFolderPath="rootFolderPath" v-model:currentFilePath="currentFilePath"
|
||||
:confirmSwitchingFile="confirmSwitchingFile">
|
||||
</LeftSidebar>
|
||||
</div>
|
||||
</template>
|
||||
<template #trigger>
|
||||
<div ref="splitTrigger" class="split-trigger"></div>
|
||||
</template>
|
||||
<template #right>
|
||||
<div ref="splitRight" class="split-right">
|
||||
<MainEditor ref="mainEditor" v-model:markdownCode="markdownCode"
|
||||
v-model:leftSidebarState="leftSidebarState" :save="writeFileContent" @exportPDF="exportMarkdownPDF">
|
||||
</MainEditor>
|
||||
</div>
|
||||
</template>
|
||||
</Split>
|
||||
<div>
|
||||
<Split v-model="split">
|
||||
<template #left>
|
||||
<div class="split-left" :class="hiddenSplitLeft">
|
||||
<LeftSidebar v-model:rootFolderPath="rootFolderPath" v-model:currentFilePath="currentFilePath">
|
||||
</LeftSidebar>
|
||||
</div>
|
||||
</template>
|
||||
<template #trigger>
|
||||
<div class="split-trigger"></div>
|
||||
</template>
|
||||
<template #right>
|
||||
<div ref="splitRight" class="split-right">
|
||||
<MarkdownEditor ref="markdownRef" width="100%" height="100%" :autoInit="false"
|
||||
:markdownCode="markdownCode" :imageUpload="true" :imageUploadURL="imageUploadURLOfEditor"
|
||||
:imageUploadURLChange="changeImageUploadURL" :onload="reloadEditorHeight"
|
||||
:onFullScreenExit="handleWindowResize" @update:markdownCode="newCode => markdownCode = newCode"
|
||||
:appendToolbar="appendToolbar" />
|
||||
</div>
|
||||
</template>
|
||||
</Split>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MarkdownEditor from './components/MarkdownEditor.vue'
|
||||
import LeftSidebar from './components/LeftSidebar.vue'
|
||||
import MainEditor from './components/MainEditor.vue'
|
||||
import { ref, watch, useTemplateRef, nextTick, onMounted, onUnmounted } from "vue";
|
||||
import { ref, watch, useTemplateRef, onMounted, onUnmounted } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { readTextFile, writeTextFile, writeFile, exists } from '@tauri-apps/plugin-fs';
|
||||
import { save, confirm } from '@tauri-apps/plugin-dialog';
|
||||
import { Message, Notice, Modal } from 'view-ui-plus'
|
||||
import { GetSingleArticle, UpdateArticle } from '/src/utils/userHandler';
|
||||
import { readTextFile, writeTextFile, exists } from '@tauri-apps/plugin-fs';
|
||||
import { Message } from 'view-ui-plus'
|
||||
|
||||
// 原始示例数据,仅供参考
|
||||
const greetMsg = ref("");
|
||||
@ -40,37 +40,24 @@ async function greet() {
|
||||
}
|
||||
|
||||
// 使用 Split 组件动态控制页面左右布局
|
||||
const defaultSplit = 0.15; // 默认左侧边栏和右侧区域的比例为 1.5:8.5
|
||||
const initSplit = localStorage.getItem('splitValueOfFolderTree');
|
||||
const split = initSplit === null ? ref(defaultSplit) : ref(Number(initSplit));
|
||||
const splitTrigger = useTemplateRef('splitTrigger');
|
||||
const split = ref(0.2);
|
||||
const splitRight = useTemplateRef('splitRight');
|
||||
const splitRightWidth = ref(0);
|
||||
const hiddenSplitLeft = ref('');
|
||||
const leftSidebarState = split.value > 0 ? ref('open') : ref('close');
|
||||
const hiddenSplitLeft = ref("");
|
||||
watch(split, (newSplit) => {
|
||||
localStorage.setItem('splitValueOfFolderTree', newSplit);
|
||||
splitRightWidth.value = splitRight.value.offsetWidth;
|
||||
// 当 split 小于某个值时,隐藏左边布局
|
||||
if (newSplit < 0.05) {
|
||||
split.value = 0;
|
||||
leftSidebarState.value = 'close';
|
||||
if (hiddenSplitLeft.value == '') {
|
||||
hiddenSplitLeft.value = 'hidden';
|
||||
split.value = 0.01
|
||||
if (hiddenSplitLeft.value == "") {
|
||||
hiddenSplitLeft.value = "hidden"
|
||||
}
|
||||
} else {
|
||||
leftSidebarState.value = 'open';
|
||||
if (hiddenSplitLeft.value == 'hidden') {
|
||||
hiddenSplitLeft.value = '';
|
||||
if (hiddenSplitLeft.value == "hidden") {
|
||||
hiddenSplitLeft.value = ""
|
||||
}
|
||||
}
|
||||
})
|
||||
watch(leftSidebarState, (state) => {
|
||||
if (state == 'open') {
|
||||
split.value = defaultSplit;
|
||||
} else {
|
||||
split.value = 0;
|
||||
}
|
||||
|
||||
// 动态调整 MarkdownEditor 的宽度。如果不执行 reset 的话,CodeMirror 内部编辑器不会自适应宽度
|
||||
reloadEditorWidth()
|
||||
})
|
||||
|
||||
// 监听根目录路径。如果之前已经选过目录,初始界面直接加载该目录
|
||||
@ -81,178 +68,113 @@ watch(rootFolderPath, (newRootPath) => {
|
||||
|
||||
// <FolderTree> 完成文件树渲染后,点击文件得到 currentFilePath
|
||||
const currentFilePath = ref(localStorage.getItem('currentSelectedFilePath') || "");
|
||||
let switchFilePath = true; // 切换文件时,避免触发 markdownCode 的 watch 事件
|
||||
// 读取 currentFilePath 的文件内容,填充到 MarkdownEditor 之中
|
||||
watch(currentFilePath, async (newFilePath) => {
|
||||
switchFilePath = true;
|
||||
localStorage.setItem('currentSelectedFilePath', newFilePath);
|
||||
await readFileContent(newFilePath);
|
||||
})
|
||||
// 切换文件时,如果当前文件的变动未保存,允许用户取消切换动作,保存当前文件内容
|
||||
const confirmSwitchingFile = () => {
|
||||
return new Promise((resolve) => {
|
||||
// 如果当前文件内容未被修改,允许切换文件
|
||||
if (currentFileSaved()) {
|
||||
resolve(true);
|
||||
return;
|
||||
|
||||
// 加载 Markdown 编辑器
|
||||
const markdownRef = ref(null);
|
||||
const markdownCode = ref("# Hello Markdown");
|
||||
const appendToolbar = [
|
||||
{
|
||||
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({
|
||||
title: '当前文件的内容变动未保存',
|
||||
content: `<p><b>${currentFilePath.value}</b> 内容被修改但仍未保存,确认切换文件?<br/><br/>注:确认切换之后,内容变动会丢失</p>`,
|
||||
okText: "仍然切换",
|
||||
cancelText: "取消切换",
|
||||
onOk: () => {
|
||||
resolve(true);
|
||||
},
|
||||
onCancel: () => {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
markdownRef.value.initEditor()
|
||||
}
|
||||
const currentFileSaved = () => {
|
||||
// 判断当前文件内容是否被修改但未保存,如果已保存则返回 true
|
||||
const lastSaveTimeValue = lastSaveTime.get(currentFilePath.value);
|
||||
const isUpdated = lastSaveTimeValue && lastUpdateTime.value > lastSaveTimeValue;
|
||||
return !isUpdated;
|
||||
// 矫正 markdown 编辑器的高度
|
||||
function reloadEditorHeight() {
|
||||
markdownRef.value.resetHeight(window.innerHeight);
|
||||
}
|
||||
const getFileNameFromFilePath = (filePath) => {
|
||||
const fullFileName = filePath.split('/').pop();
|
||||
const splitResult = fullFileName.split(".");
|
||||
splitResult.pop(); // 丢掉后缀
|
||||
return splitResult.join('.');
|
||||
// 矫正 markdown 编辑器的宽度
|
||||
function reloadEditorWidth() {
|
||||
// 可以使用 splitRight.value.offsetWidth 也可以使用 100% 来调整
|
||||
const width = splitRight.value.offsetWidth;
|
||||
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) {
|
||||
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 {
|
||||
const fileContent = await readTextFile(filePath);
|
||||
markdownCode.value = fileContent;
|
||||
markdownRef.value.setMarkdownCode(await readTextFile(filePath));
|
||||
currentFilePath.value = filePath;
|
||||
lastSaveTime.set(filePath, new Date().getTime());
|
||||
Message.success('已读取文件内容,并加载到编辑器中:' + filePath);
|
||||
} catch (err) {
|
||||
Message.error('文件读取失败:' + err);
|
||||
}
|
||||
}
|
||||
async function writeFileContent(markdownCode) {
|
||||
async function writeFileContent() {
|
||||
// 如果未选择任何文件,则不触发写文件事件
|
||||
if (currentFilePath.value.length == 0) {
|
||||
Message.warning('当前编辑器暂未关联目标文件,请在左侧打开目录并选择一个文件')
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (window.__TAURI_INTERNALS__ === undefined) {
|
||||
// 调用接口更新 currentFilePath 文件内容
|
||||
const match = currentFilePath.value.match(/(\d+)$/);
|
||||
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);
|
||||
const exist = await exists(currentFilePath.value);
|
||||
if (!exist) {
|
||||
throw new Error(currentFilePath.value + " 文件不存在.");
|
||||
}
|
||||
lastSaveTime.set(currentFilePath.value, new Date().getTime());
|
||||
await writeTextFile(currentFilePath.value, markdownRef.value.getMarkdownCode())
|
||||
Message.success('文件更新成功:' + currentFilePath.value);
|
||||
} catch (err) {
|
||||
Message.error('文件更新失败:' + err);
|
||||
}
|
||||
}
|
||||
async function exportMarkdownPDF(pdfBuffer) {
|
||||
let fileName = 'markdown.pdf';
|
||||
if (currentFilePath.value.length > 0) {
|
||||
fileName = getFileNameFromFilePath(currentFilePath.value) + '.pdf';
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = await save({
|
||||
title: '保存 PDF', // 对话框标题
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
initMarkdownEditor();
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
});
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
body {
|
||||
overflow: hidden;
|
||||
/* 禁用所有方向滚动 */
|
||||
@ -267,9 +189,9 @@ body {
|
||||
}
|
||||
|
||||
.split-trigger {
|
||||
width: 2px;
|
||||
width: 5px;
|
||||
height: 100vh;
|
||||
background-color: #e6e6e6;
|
||||
background-color: gray;
|
||||
}
|
||||
|
||||
.split-trigger:hover {
|
||||
@ -277,10 +199,11 @@ body {
|
||||
}
|
||||
|
||||
.split-right {
|
||||
margin-left: 2px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
<style></style>
|
||||
|
||||
@ -60,14 +60,6 @@ export default {
|
||||
required: false,
|
||||
default: [],
|
||||
},
|
||||
// 切换节点前的确认函数。返回 Promise 对象,resolve(true) 表示切换节点,resolve(false) 表示取消切换节点
|
||||
confirmSwitchingNode: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default: () => new Promise((resolve) => {
|
||||
resolve(true);
|
||||
}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -228,22 +220,15 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
this.confirmSwitchingNode().then((result) => {
|
||||
// 如果用户取消了切换文件,则不做任何操作
|
||||
if (result === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除其他选中状态,为当前节点增加选中状态
|
||||
let selectedNodes = this.$refs.tree.getSelectedNodes();
|
||||
selectedNodes.forEach(element => {
|
||||
element.selected = false;
|
||||
});
|
||||
nodeRenderData.selected = true;
|
||||
|
||||
// 向父组件暴露当前选中的文件
|
||||
this.$emit('file-selected', nodeRenderData);
|
||||
// 清除其他选中状态,为当前节点增加选中状态
|
||||
let selectedNodes = this.$refs.tree.getSelectedNodes()
|
||||
selectedNodes.forEach(element => {
|
||||
element.selected = false
|
||||
});
|
||||
nodeRenderData.selected = true
|
||||
|
||||
// 向父组件暴露当前选中的文件
|
||||
this.$emit('file-selected', nodeRenderData);
|
||||
},
|
||||
// 在父节点插入子节点
|
||||
insertTreeNode(parentRenderData, nodeData, index) {
|
||||
@ -1,23 +1,14 @@
|
||||
<template>
|
||||
<Space direction="vertical" style="margin-left: 5px; margin-top: 5px;">
|
||||
<Space>
|
||||
<Button v-if="!loginStatus" type="primary" shape="circle" icon="md-person"
|
||||
@click="showLoginModal = true"></Button>
|
||||
<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>
|
||||
<SelectFolder :rootPath="rootFolderPath" :nodeDataCallback="appendDataForNode" @update:rootPath="changeRootPath"
|
||||
@folder-selected="showFileTree" />
|
||||
<FolderTree ref="folderTreeRef" :treeData="folderTreeData" :expandLevel="1" :selectedNode="[currentFilePath]"
|
||||
:specifyFileSuffix="['md']" @file-selected="fileSelected" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import UserLogin from './UI/UserLogin.vue';
|
||||
import SelectFolder from './UI/SelectFolder.vue';
|
||||
import FolderTree from './UI/FolderTree.vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import SelectFolder from './SelectFolder.vue';
|
||||
import FolderTree from './FolderTree.vue';
|
||||
import { ref } from 'vue';
|
||||
import { mkdir, remove, create, exists, rename } from '@tauri-apps/plugin-fs';
|
||||
import { Message } from 'view-ui-plus'
|
||||
|
||||
@ -32,11 +23,6 @@ defineProps({
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
confirmSwitchingFile: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default: undefined,
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@ -44,22 +30,6 @@ const emit = defineEmits([
|
||||
'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)
|
||||
// <SelectFolder> 返回的文件树数据,增加自定义数据,便于传给 <FolderTree> 进行渲染
|
||||
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 -->
|
||||
<link rel="stylesheet" href="/static/CmdMarkdown/custom.css">
|
||||
|
||||
<div :id="editorID"></div>
|
||||
<div id="markdown-editor"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -15,11 +15,6 @@ let editorClient = defaultEditorValue;
|
||||
export default {
|
||||
name: 'MarkdownEditor',
|
||||
props: {
|
||||
editorID: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'markdown-editor'
|
||||
},
|
||||
// 加载该组件时,是否自动执行 initEditor
|
||||
// 如果为 false,则将初始化的时机和主动权交给父组件
|
||||
autoInit: {
|
||||
@ -111,7 +106,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loadingCompleted: false,
|
||||
editormdPreview: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@ -139,7 +133,7 @@ export default {
|
||||
// 加载完成以上 JS 资源之后,再加载编辑器
|
||||
this.$nextTick(() => {
|
||||
const vm = this
|
||||
const editor = window.editormd(vm.editorID, {
|
||||
const editor = window.editormd('markdown-editor', {
|
||||
// 初始化内容
|
||||
markdown: vm.markdownCode,
|
||||
// 定义宽度和高度
|
||||
@ -196,7 +190,6 @@ export default {
|
||||
// 将 editor 变量赋予 editorClient,便于后续调用 API
|
||||
editorClient = this
|
||||
vm.loadingCompleted = true
|
||||
vm.editormdPreview = $('#' + vm.editorID + ' .editormd-preview-container')[0]
|
||||
// 执行父组件传递的 callback 方法
|
||||
vm.onload()
|
||||
|
||||
@ -256,105 +249,8 @@ export default {
|
||||
}
|
||||
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>
|
||||
|
||||
<style scoped></style>
|
||||
@ -35,8 +35,6 @@
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { readDir } from '@tauri-apps/plugin-fs';
|
||||
import { GetUserArticle } from '/src/utils/userHandler';
|
||||
import { Message } from 'view-ui-plus';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@ -61,39 +59,12 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
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) {
|
||||
const folderTreeData = await this.readDirRecursively(folderPath);
|
||||
this.$emit('folder-selected', folderTreeData);
|
||||
}
|
||||
},
|
||||
async selectFolder() {
|
||||
if (window.__TAURI_INTERNALS__ === undefined) {
|
||||
// 非 Tauri 环境下,会默认加载用户数据列表,不支持选择目录
|
||||
return;
|
||||
}
|
||||
const folderPath = await open(
|
||||
{
|
||||
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