基于IndexDB+md-editor-v3实现的简单的文章书写小系统
- 文章说明
- 核心代码
- 效果展示
- 源码下载
文章说明
采用vue3 + IndexDB 实现的个人仓库系统,采用markdown书写文章,并将文章信息存储在IndexDB数据库中,通过JavaScript原生自带的分词API进行文章的搜索
核心代码
采用SpringBoot简单搭建了一个图片服务器,之前一直想通过前台实现图片的上传和下载,但是采用vue的代理我试了很久,都实现不了;还是采用后台服务器技术来的简洁方便。
前台就采用md-editor-v3进行文章的书写和阅读,然后自己简单的采用基于分词和计数的方式来实现了一个简单的搜索引擎效果;目前只是作为一个示例,供学习使用。
图片服务器核心代码
package com.boot.controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import static com.boot.config.WebConfig.imageDir;
@RestController
@CrossOrigin(origins = "*", maxAge = 3600)
@RequestMapping("/image")
public class ImageController {
@PostMapping("/upload")
public void upload(@RequestBody MultipartFile file, @RequestParam String id) throws Exception {
File dir = new File(imageDir + id);
if (!dir.exists()) {
if (!dir.mkdirs()) {
System.out.println("图片文件存放文件夹," + imageDir + id + "创建失败");
}
}
file.transferTo(new File(imageDir + id + File.separator + file.getOriginalFilename()));
}
}
配置类;采用路径映射,简单的对图片进行存储和预览;registry.addResourceHandler(“/image/**”).addResourceLocations(“file:” + imageDir);
package com.boot.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.io.File;
@Configuration
public class WebConfig implements WebMvcConfigurer {
public static String imageDir;
@Value("${image.dir}")
public void setImageDir(String imageDir) {
WebConfig.imageDir = imageDir;
File file = new File(imageDir);
if (!file.exists()) {
if (file.mkdirs()) {
System.out.println("图片文件夹," + imageDir + "创建成功");
} else {
System.out.println("图片文件夹," + imageDir + "创建失败");
System.exit(0);
}
}
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/image/**")
.addResourceLocations("file:" + imageDir);
}
}
文章书写页面代码
<script setup>
import {onBeforeMount, reactive, watch} from "vue";
import {MdEditor} from 'md-editor-v3';
import 'md-editor-v3/lib/style.css';
import {baseUrl, message, splitText, uploadImage} from "@/util";
import {onBeforeRouteLeave, useRoute, useRouter} from "vue-router";
import {openArticleLog, openContentWordLog, openTitleWordLog} from "@/util/config";
import {dbOperation} from "@/util/dbOperation";
import {ElLoading} from "element-plus";
const data = reactive({
text: "",
visible: false,
title: "",
id: "",
modFlag: false,
});
watch(() => data.text, () => {
data.modFlag = true;
});
const route = useRoute();
onBeforeMount(async () => {
data.id = route.query.id;
if (data.id) {
await openArticleLog();
const res = await dbOperation.getDataByField("id", Number(data.id));
const article = res.data[0];
data.title = article.article_title;
data.text = article.article_content;
}
window.onbeforeunload = function () {
if (data.modFlag) {
write();
}
}
});
onBeforeRouteLeave(() => {
if (data.modFlag) {
write();
return true;
}
});
async function onUploadImg(files, callback) {
for (let i = 0; i < files.length; i++) {
const imagePath = await uploadImage(files[i]);
callback([baseUrl + "/image/" + imagePath]);
}
message("图片添加成功", "success");
}
function onSave() {
data.visible = true;
}
const router = useRouter();
async function write() {
data.modFlag = false;
const loadingInstance = ElLoading.service({
background: "rgba(0, 0, 0, 0.7)",
text: "正在保存中,请稍候"
});
if (data.title === "") {
data.title = "草稿";
}
if (data.id) {
await openArticleLog();
await dbOperation.update({
id: Number(data.id),
article_title: data.title,
article_content: data.text,
create_time: new Date(),
});
} else {
await openArticleLog();
await dbOperation.add([
{
article_title: data.title,
article_content: data.text,
create_time: new Date(),
}
]);
const newArticle = await dbOperation.getLimitOrderDescData("id", "1");
if (newArticle.data.length > 0) {
data.id = newArticle.data[0].id;
}
}
await dealTitleWord();
await dealContentWord();
data.visible = false;
loadingInstance.close();
message("文章保存成功", "success");
await router.push({
path: "/index",
});
}
async function dealTitleWord() {
const titleWords = splitText(data.title);
for (let i = 0; i < titleWords.length; i++) {
const segment = titleWords[i].segment;
await openTitleWordLog();
const titleWordRes = await dbOperation.getDataByField("title_word", segment);
if (titleWordRes.data.length > 0) {
const titleWord = titleWordRes.data[0];
const article_id_list = titleWord.article_id_list;
if (article_id_list.indexOf(data.id) === -1) {
article_id_list.push(data.id);
await dbOperation.update({
id: titleWord.id,
title_word: segment,
article_id_list: article_id_list,
create_time: new Date(),
});
}
} else {
await dbOperation.add([
{
title_word: segment,
article_id_list: [data.id],
create_time: new Date(),
}
]);
}
}
}
async function dealContentWord() {
const contentWords = splitText(data.text);
for (let i = 0; i < contentWords.length; i++) {
const segment = contentWords[i].segment;
await openContentWordLog();
const contentWordRes = await dbOperation.getDataByField("content_word", segment);
if (contentWordRes.data.length > 0) {
const contentWord = contentWordRes.data[0];
const article_id_list = contentWord.article_id_list;
if (article_id_list.indexOf(data.id) === -1) {
article_id_list.push(data.id);
await dbOperation.update({
id: contentWord.id,
content_word: segment,
article_id_list: article_id_list,
create_time: new Date(),
});
}
} else {
await dbOperation.add([
{
content_word: segment,
article_id_list: [data.id],
create_time: new Date(),
}
]);
}
}
}
</script>
<template>
<MdEditor v-model="data.text" :toolbarsExclude="['github']" style="height: 100%; width: 100%"
@onSave="onSave" @on-upload-img="onUploadImg"/>
<el-dialog v-model="data.visible" title="文章标题" width="80%">
<el-input v-model="data.title" :rows="3" resize="none" type="textarea"/>
<template #footer>
<el-button @click="data.visible = false">关闭</el-button>
<el-button type="primary" @click="write">保存</el-button>
</template>
</el-dialog>
</template>
<style lang="scss" scoped>
</style>
文章搜索页面代码
<script setup>
import {onBeforeMount, reactive} from "vue";
import {confirm, message, splitText} from "@/util";
import {useRouter} from "vue-router";
import {CONTENT_RATE, openArticleLog, openContentWordLog, openTitleWordLog, TITLE_RATE} from "@/util/config";
import {dbOperation} from "@/util/dbOperation";
const data = reactive({
searchInput: "",
articleList: [],
});
onBeforeMount(() => {
initData();
});
async function initData() {
await openArticleLog();
const res = await dbOperation.getLimitOrderDescData("create_time", 10);
data.articleList = res.data;
}
const router = useRouter();
function preview(item) {
router.push({
path: "/article",
query: {
id: item.id,
},
});
}
function edit(item) {
router.push({
path: "/write",
query: {
id: item.id,
},
});
}
function deleteArticle(item) {
confirm("确认删除该文章吗?", async () => {
await openArticleLog();
await dbOperation.delete([Number(item.id)]);
message("文章删除成功", "success");
await initData();
});
}
async function search() {
if (data.searchInput.trim().length === 0) {
initData();
return;
}
if (data.searchInput.length > 20) {
message("搜索词最长20个字符", "warning");
return;
}
const searchWords = splitText(data.searchInput);
const titleList = [];
for (let i = 0; i < searchWords.length; i++) {
const segment = searchWords[i].segment;
await openTitleWordLog();
const titleWordRes = await dbOperation.getDataByField("title_word", segment);
if (titleWordRes.data.length > 0) {
titleList.push(titleWordRes.data[0].article_id_list);
}
}
const contentList = [];
for (let i = 0; i < searchWords.length; i++) {
const segment = searchWords[i].segment;
await openContentWordLog();
const contentWordRes = await dbOperation.getDataByField("content_word", segment);
if (contentWordRes.data.length > 0) {
contentList.push(contentWordRes.data[0].article_id_list);
}
}
const articleIdMap = {};
for (let i = 0; i < titleList.length; i++) {
const article_id_list = titleList[i];
for (let j = 0; j < article_id_list.length; j++) {
if (!articleIdMap[article_id_list[j]]) {
articleIdMap[article_id_list[j]] = TITLE_RATE;
} else {
articleIdMap[article_id_list[j]] += TITLE_RATE;
}
}
}
for (let i = 0; i < contentList.length; i++) {
const article_id_list = contentList[i];
for (let j = 0; j < article_id_list.length; j++) {
if (!articleIdMap[article_id_list[j]]) {
articleIdMap[article_id_list[j]] = CONTENT_RATE;
} else {
articleIdMap[article_id_list[j]] += CONTENT_RATE;
}
}
}
const articleIdList = [];
Object.keys(articleIdMap).forEach(function (key) {
articleIdList.push({
articleId: key,
count: articleIdMap[key],
});
});
articleIdList.sort(function (o1, o2) {
return o2.count - o1.count;
});
const articleList = [];
for (let i = 0; i < articleIdList.length; i++) {
await openArticleLog();
const articleRes = await dbOperation.getDataByField("id", Number(articleIdList[i].articleId));
if (articleRes.data.length > 0) {
articleList.push(articleRes.data[0]);
}
}
data.articleList = articleList;
}
</script>
<template>
<div style="width: 100%; height: 100%; padding: 1rem">
<el-row style="margin-bottom: 1rem; justify-content: center">
<el-input v-model="data.searchInput" placeholder="请输入搜索" size="large" style="width: 30rem" @change="search"/>
</el-row>
<template v-for="item in data.articleList" :key="item.id">
<el-card shadow="hover" style="width: 100%; margin-bottom: 1rem">
<h3>{{ item.article_title }}</h3>
<p style="float: right; font-size: 0.8rem">{{ item.create_time }}</p>
<el-row style="margin-top: 1rem">
<el-button type="primary" @click="preview(item)" class="btn">查看</el-button>
<el-button type="info" @click="edit(item)" class="btn">编辑</el-button>
<el-button type="danger" @click="deleteArticle(item)" class="btn">删除</el-button>
</el-row>
</el-card>
</template>
</div>
</template>
<style lang="scss" scoped>
.btn {
width: 4rem;
height: 2rem;
line-height: 2rem;
}
</style>
在书写了一些简单的小项目后,感到indexDB这个数据库还是有它的优点的,使用便捷,部署方便。我也简单的书写了一个工具类,之前一直都是采用回调函数的方式,在层次较深时逻辑很不清晰,采用Promise的形式,使用起来更加方便一些
import {message} from "@/util/index";
class DbOperation {
request = undefined;
db = undefined;
dbName = undefined;
tableName = undefined;
fieldList = undefined;
init(dbName, tableList) {
const request = window.indexedDB.open(dbName);
request.onsuccess = function (event) {
dbOperation.db = event.target["result"];
};
request.onupgradeneeded = function (event) {
dbOperation.db = event.target.result;
for (let i = 0; i < tableList.length; i++) {
createTable(tableList[i].tableName, tableList[i].fieldList);
}
};
function createTable(tableName, fieldList) {
if (!dbOperation.db.objectStoreNames.contains(tableName)) {
const objectStore = dbOperation.db.createObjectStore(tableName, {
keyPath: "id",
autoIncrement: true
});
for (let i = 0; i < fieldList.length; i++) {
objectStore.createIndex(fieldList[i], fieldList[i]);
}
}
}
}
open(dbName, tableName, fieldList) {
return new Promise((resolve) => {
dbOperation.dbName = dbName;
dbOperation.tableName = tableName;
dbOperation.fieldList = fieldList;
const request = window.indexedDB.open(dbName);
dbOperation.request = request;
request.onsuccess = function (event) {
dbOperation.db = event.target["result"];
resolve();
};
});
}
getObjectStore() {
const transaction = dbOperation.db.transaction(dbOperation.tableName, "readwrite");
return transaction.objectStore(dbOperation.tableName);
}
add(dataList) {
return new Promise((resolve) => {
if (dbOperation.dbName === undefined) {
message("数据库还未打开", "warning");
return;
}
const transaction = dbOperation.db.transaction(dbOperation.tableName, "readwrite");
const objectStore = transaction.objectStore(dbOperation.tableName);
for (let i = 0; i < dataList.length; i++) {
objectStore.add(dataList[i]);
}
transaction.oncomplete = () => {
resolve();
};
});
}
update(newData) {
return new Promise((resolve) => {
if (dbOperation.dbName === undefined) {
message("数据库还未打开", "warning");
return;
}
const objectStore = dbOperation.getObjectStore();
const men = objectStore.put(newData);
men.onsuccess = function () {
resolve();
};
});
}
delete(idValueList) {
return new Promise((resolve) => {
if (dbOperation.dbName === undefined) {
message("数据库还未打开", "warning");
return;
}
const transaction = dbOperation.db.transaction(dbOperation.tableName, "readwrite");
const objectStore = transaction.objectStore(dbOperation.tableName);
for (let i = 0; i < idValueList.length; i++) {
objectStore.delete(idValueList[i]);
}
transaction.oncomplete = () => {
resolve();
};
});
}
getAllData() {
return new Promise((resolve) => {
if (dbOperation.dbName === undefined) {
message("数据库还未打开", "warning");
return;
}
const objectStore = dbOperation.getObjectStore();
const men = objectStore.openCursor();
const data = [];
men.onsuccess = function (event) {
const row = event.target["result"];
if (row) {
data.push(row.value);
row.continue();
} else {
resolve({
data: data,
});
}
};
});
}
getLimitOrderDescData(fieldName, limit) {
return new Promise((resolve) => {
if (dbOperation.dbName === undefined) {
message("数据库还未打开", "warning");
return;
}
const objectStore = dbOperation.getObjectStore().index(fieldName);
const men = objectStore.openCursor(null, "prev");
const data = [];
men.onsuccess = function (event) {
const row = event.target["result"];
if (row) {
if (data.length >= limit) {
resolve({
data: data,
});
return;
}
data.push(row.value);
row.continue();
} else {
resolve({
data: data,
});
}
};
});
}
getDataByField(fieldName, fieldValue) {
return new Promise((resolve) => {
if (dbOperation.dbName === undefined) {
message("数据库还未打开", "warning");
return;
}
const objectStore = dbOperation.getObjectStore().index(fieldName);
const men = objectStore.openCursor(IDBKeyRange.only(fieldValue));
const data = [];
men.onsuccess = function (event) {
const row = event.target["result"];
if (row) {
data.push(row.value);
row.continue();
} else {
resolve({
data: data,
});
}
};
});
}
}
export const dbOperation = new DbOperation();
效果展示
搜索页面
书写页面
阅读页面
编辑页面
采用electron打包为桌面应用
源码下载
冰冰一号的个人仓库系统