我使用了Vditor这个Markdown编辑器组件作为我的文章编辑器。
src/components/comments.vue
我将Vidtor的项目代码放在本地/public路径下,使用本地资源,加载更快。给出的代码注释掉了,将会使用在线CDN。
<template>
<!-- 指定一个容器 -->
<div id="vditor" style="margin-top: 10px;"></div>
</template>
<script setup>
import Vditor from 'vditor'
import 'vditor/dist/index.css';
import { ref, onMounted } from 'vue';
// 2. 获取DOM引用
const editor = ref()
const isReady = ref(false); // 标志位,表示 Vditor 是否已初始化
onMounted(() => {
editor.value = new Vditor('vditor',{
height: '80vh',
width: '60vw',
mode: 'wysiwyg', //编辑模式设置为即时渲染
cdn: '/vditor', // 设置为本地CDN
icon: 'material',
after() {
isReady.value = true;
console.log('Vditor 初始化完成');
const event = new CustomEvent('editor-ready'); // 通知父组件编辑器已准备好
document.dispatchEvent(event);
},
counter: {
enable: true, // 是否启用计数器
},
cache: {
enable: false, // 不保存原来内容
},
preview: {
maxWidth: 1000,
hljs:{ // 启用代码高亮
enable: true,
style: "monokai",
lineNumber: true, // 显示行号
}
},
});
});
// 向父组件暴露获取编辑器内容的方法
const getValue = () => {
return editor.value?.getValue() || '';
};
const setValue = (value) => {
editor.value?.setValue(value);
};
const getHTML = () => {
return editor.value?.getHTML() || '';
};
defineExpose({
getValue, // 父组件可以调用这个方法获取内容
setValue,
getHTML,
isReady, // 暴露 isReady 标志
});
</script>
HTML
快速编辑器
这个编辑器封装了除了文章内容和标题意外的其他属性,可复用。
QucikEdit.vue
<template>
<div class ="quickEdit">
<div class="editItem">
<h3>发布</h3>
<div class="content">
<span>状态:</span>
<select v-model="statusComputed" id="status" class="status">
<option value="publish">立即发布</option>
<option value="draft">草稿</option>
<option value="private">私密</option>
<option value="trash">垃圾</option>
</select><br />
</div>
</div>
<div class="editItem">
<h3>时间</h3>
<div class="content">
<input v-model="timeComputed" type="datetime-local"><br />
</div>
</div>
<div class="editItem">
<h3>分类</h3>
<div class="content">
<select v-model="categoryComputed" id="categories">
<option v-for="category in categories" :key="category.archiveId" :value="category">
{{ category.name }}
</option>
</select>
</div>
</div>
<div class="editItem">
<h3>标签</h3>
<div class="content">
<input v-model="inputTagText" type="text" style="height: 24px;">
<button @click="addTags" style="height: 30px; margin-left: 10px;">添加</button>
</div>
</div>
<div class="editItem">
<ul class="content">
<li v-for="(tag, index) in tagsComputed" :key="index">{{ tag.name }}
<span @click="removeTag(index)" style="cursor: pointer; margin-left: 10px; color: red;">删除</span>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, defineProps, defineEmits } from 'vue';
// 接收父组件的props
const props = defineProps({
categories: Array,
status: String,
time: String,
category: Object,
tags: Array,
});
// 需要对父组件传递的 `status`, `time`, `category` 和 `tags` 进行响应式绑定
const localStatus = ref(props.status);
const localTime = ref(props.time);
const localCategory = ref(props.category);
const localTags = ref([...props.tags]);
const inputTagText = ref('');
const emit = defineEmits(['update:status', 'update:time', 'update:category', 'update:tags']);
// 调试信息
console.log('父组件传入的 props:', props);
// 格式化日期,适用于 <input type="datetime-local">
const formatDate = (dateStr) => {
const localDate = new Date(dateStr);
const year = localDate.getFullYear().toString().padStart(4, '0');
const month = (localDate.getMonth() + 1).toString().padStart(2, '0');
const day = localDate.getDate().toString().padStart(2, '0');
const hour = localDate.getHours().toString().padStart(2, '0');
const minute = localDate.getMinutes().toString().padStart(2, '0');
return `${year}-${month}-${day}T${hour}:${minute}`;
};
// 使用 watchEffect 来确保父组件传入的数据发生变化时,子组件能够同步更新
watch(() => props.status, (newStatus) => {
console.log('状态更新为:', newStatus);
localStatus.value = newStatus;
});
watch(() => props.time, (newTime) => {
console.log('时间更新为:', newTime);
localTime.value = newTime;
});
watch(() => props.category, (newCategory) => {
console.log('分类更新为:', newCategory);
localCategory.value = newCategory;
});
watch(() => props.tags, (newTags) => {
console.log('标签更新为:', newTags);
localTags.value = [...newTags];
});
// 根据本地的值来更新父组件的值
const statusComputed = computed({
get: () => localStatus.value,
set: (newValue) => {
localStatus.value = newValue;
emit('update:status', newValue);
}
});
const timeComputed = computed({
get: () => formatDate(localTime.value),
set: (newValue) => {
localTime.value = newValue;
emit('update:time', newValue);
}
});
const categoryComputed = computed({
get: () => localCategory.value,
set: (newValue) => {
localCategory.value = newValue;
emit('update:category', newValue);
}
});
const tagsComputed = computed({
get: () => localTags.value,
set: (newValue) => {
localTags.value = newValue;
emit('update:tags', newValue);
}
});
const addTags = () => {
if (inputTagText.value) {
const newTag = {
archiveId: null,
taxonomy: 'post_tag',
name: inputTagText.value
};
localTags.value.push(newTag);
emit('update:tags', [...localTags.value]); // 确保同步到父组件
inputTagText.value = '';
}
console.log(tagsComputed.value);
}
const removeTag = (index) => {
tagsComputed.value.splice(index, 1); // 删除指定位置的标签
console.log(tagsComputed.value);
emit('update:tags', tagsComputed.value); // 确保同步到父组件
};
// 确保异步加载数据后再进行其他操作
onMounted(() => {
console.log('子组件已挂载,等待父组件传递的数据...');
});
</script>
HTML
添加文章页面
Post-new.vue
<template>
<div style="display: flex; flex-direction: column; min-height: 100vh;">
<div style="flex: 1; display: flex; margin-left: 50px;">
<!-- 使用 QuickEdit 组件,并传递数据 -->
<QuickEdit
:categories="categories"
v-model:status="status"
v-model:time="time"
v-model:category="category"
v-model:tags="tags"
/>
<div style="width: min-content;">
<h3>文章标题:</h3>
<input v-model="articletitle" placeholder="添加标题" class="articletitle" />
<div style="margin: 10px 0px; display: flex; gap: 10px;">
<button @click="showHTML">显示HTML</button>
<button @click="showMarkdown">显示Markdown</button>
<button v-if="!route.params.id" id="addButton" @click="addButton">发布文章</button>
</div>
<Editor ref="editorRef" @editor-ready="onEditorReady" />
<p v-if="message" :class="{ success: isSuccess, error: !isSuccess }"> {{ message }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';
import Editor from '@/components/Editor.vue';
import QuickEdit from '@/components/QuickEdit.vue';
import axios from 'axios';
const route = useRoute();
const editorRef = ref(null); // 用于获取 Editor 的实例
const message = ref(''); // 提示信息
const articletitle = ref(''); //文章标题
const isSuccess = ref(true); // 提示信息类型(成功/失败)
const isSubmitting = ref(false); // 提交状态
const time = ref('');
const status = ref('publish');
const categories = ref([]);
const category = ref(null);
const tags = ref([]);
const clear = () => {
category.value = categories.value[0];
tags.value = [];
articletitle.value = '';
editorRef.value.setValue('');
}
const saveTags = async () => {
let temptags = tags.value;
tags.value = [];
for (const tag of temptags) {
try {
const response = await axios.post('http://localhost:8080/admin/archives', {
name: tag.name,
taxonomy: 'post_tag'
});
tags.value.push(response.data);
console.log(`Tag "${tag}" saved successfully.`);
} catch (error) {
console.error(`保存标签 "${tag}" 失败:`, error);
}
}
}
const addButton = async () => {
if (!articletitle.value.trim()) {
message.value = "标题不能为空!";
isSuccess.value = false;
return;
}
isSubmitting.value = true;
try {
// 等待标签保存完成
await saveTags();
// 文章提交请求
const response = await axios.post('http://localhost:8080/admin/articles', {
title: articletitle.value,
content: editorRef.value.getValue(),
postDate: new Date(),
updateDate: new Date(),
status: status.value,
likes: 0,
views: 0,
category: category.value,
tags: tags.value
});
if (response.status === 201 && response.data) {
isSuccess.value = true;
message.value = "提交成功";
clear(); // 清空结果
} else {
throw new Error("提交失败,请稍后重试。");
}
} catch (error) {
console.error("提交文章错误", error);
isSuccess.value = false;
message.value = "提交失败";
} finally {
isSubmitting.value = false;
}
};
const showHTML = () => {
message.value = editorRef.value.getHTML();
}
const showMarkdown = () => {
message.value = editorRef.value.getValue();
}
onMounted(() => {
// 监听编辑器准备完成的事件
document.addEventListener('editor-ready', onEditorReady);
loadArchives();
// time.value = formatDate(Date());
time.value = Date();
// 在页面销毁时移除监听器,防止内存泄漏
onUnmounted(() => {
document.removeEventListener('editor-ready', onEditorReady);
});
});
// 当编辑器准备好时的处理逻辑
const onEditorReady = () => {
console.log("Editor is ready. Loading article...");
};
const loadArchives = () => {
axios.get('http://localhost:8080/admin/archives', {
params: {
page: 1,
limit: 1000,
sort: 'id',
order: 'asc',
taxonomy: 'category'
}
}).then(response => {
categories.value = response.data.content;
category.value = categories.value[0];
}).catch(error => {
console.error('请求分类存档列表失败:', error);
});
}
</script>
<style>
textarea {
width: 100%;
margin-bottom: 10px;
padding: 10px;
}
.articletitle {
width: calc(100% - 20px);
margin-bottom: 10px;
padding: 5px 10px;
}
.success {
color: green;
}
.error {
color: red;
}
button {
padding: 5px 10px;
margin-right: 10px;
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
}
</style>
HTML
文章编辑页面
Edit.vue
<template>
<div style="display: flex; flex-direction: column; min-height: 100vh;">
<div style="flex: 1; display: flex; margin-left: 50px;">
<!-- 使用 QuickEdit,并传递数据 -->
<QuickEdit
:categories="categories"
v-model:status="status"
v-model:time="time"
v-model:category="category"
v-model:tags="tags"
/>
<!-- 文章标题和编辑器 -->
<div style="width: min-content;">
<h3>文章标题:</h3>
<input v-model="articletitle" placeholder="添加标题" class="articletitle" />
<div style="margin: 10px 0px; display: flex; gap: 10px;">
<button @click="showHTML">显示HTML</button>
<button @click="showMarkdown">显示Markdown</button>
<button v-if="route.params.id" id="updateButton" @click="updateButton">提交修改</button>
</div>
<Editor ref="editorRef" @editor-ready="onEditorReady" />
<p v-if="message" :class="{ success: isSuccess, error: !isSuccess }"> {{ message }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';
import Editor from '@/components/Editor.vue';
import QuickEdit from '@/components/QuickEdit.vue';
import axios from 'axios';
const route = useRoute();
const editorRef = ref(null); // 用于获取 Editor 的实例
const message = ref(''); // 提示信息
const articletitle = ref(''); //文章标题
const isSuccess = ref(true); // 提示信息类型(成功/失败)
const isSubmitting = ref(false); // 提交状态
const time = ref('');
const status = ref('publish');
const categories = ref([]);
const category = ref(null);
const tags = ref([]);
const likes = ref(0);
const views = ref(0);
const saveTags = async () => {
let temptags = tags.value;
tags.value = [];
for (const tag of temptags) {
try {
const response = await axios.post('http://localhost:8080/admin/archives', {
name: tag.name,
taxonomy: 'post_tag'
});
tags.value.push(response.data);
console.log(`Tag "${tag}" saved successfully.`);
} catch (error) {
console.error(`保存标签 "${tag}" 失败:`, error);
}
}
}
const updateButton = async () => {
if (!articletitle.value.trim()) {
message.value = "标题不能为空!";
isSuccess.value = false;
return;
}
isSubmitting.value = true;
await saveTags();
const id = route.params.id;
try {
const response = await axios.put('http://localhost:8080/admin/articles', {
articleId: id,
title: articletitle.value,
content: editorRef.value.getValue(),
postDate: new Date(time.value),
updateDate: new Date(),
status: status.value,
likes: likes.value,
views: views.value,
category: category.value,
tags: tags.value
});
if (response.status === 200 && response.data) {
isSuccess.value = true;
message.value = "修改成功";
} else {
throw new Error("修改失败,请稍后重试。");
}
} catch (error) {
console.error("修改文章错误", error);
isSuccess.value = false;
message.value = "修改失败";
} finally {
isSubmitting.value = false;
}
};
const showHTML = () => {
message.value = editorRef.value.getHTML();
}
const showMarkdown = () => {
message.value = editorRef.value.getValue();
}
onMounted(() => {
// 监听编辑器准备完成的事件
document.addEventListener('editor-ready', onEditorReady);
// 在页面销毁时移除监听器,防止内存泄漏
onUnmounted(() => {
document.removeEventListener('editor-ready', onEditorReady);
});
});
// 当编辑器准备好时的处理逻辑
const onEditorReady = () => {
console.log("Editor is ready. Loading article...");
loadArticle(); // 加载文章内容
loadCategory();
};
const loadArticle = async () => {
if (route.path === '/admin/edit') {
document.title = '清科谷体的博客 - 新建文章';
// time.value = formatDate(new Date());
time.value = new Date();
return;
}
const id = route.params.id;
try {
const response = await axios.get(`http://localhost:8080/articles/${id}`);
articletitle.value = response.data.title;
editorRef.value.setValue(response.data.content);
status.value = response.data.status;
//time.value = formatDate(response.data.postDate);
time.value = response.data.postDate;
category.value = response.data.category;
tags.value = response.data.tags;
likes.value = response.data.likes;
views.value = response.data.views;
document.title = '编辑文章 - ' + articletitle.value;
} catch {
console.error(`不能读取文章`);
}
};
const loadCategory = () => {
axios.get('http://localhost:8080/admin/archives', {
params: {
page: 1,
limit: 1000,
sort: 'id',
order: 'asc',
taxonomy: 'category'
}
}).then(response => {
categories.value = response.data.content;
}).catch(error => {
console.error('请求分类存档列表失败:', error);
});
}
</script>
<style scoped>
textarea {
width: 100%;
margin-bottom: 10px;
padding: 10px;
}
.articletitle {
width: calc(100% - 20px);
margin-bottom: 10px;
padding: 5px 10px;
}
.success {
color: green;
}
.error {
color: red;
}
button {
padding: 5px 10px;
margin-right: 10px;
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
}
</style>
HTML
文章评论