基于IndexDB+md-editor-v3实现的简单的文章书写小系统

news2024/12/22 14:10:45

基于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打包为桌面应用
在这里插入图片描述

源码下载

冰冰一号的个人仓库系统

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2119788.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

盘点10款顶级加密软件,让企业数据安全得到保障!

随着数字化进程的加快&#xff0c;企业数据的安全性面临着越来越多的威胁。无论是内部的数据泄露还是外部的网络攻击&#xff0c;企业必须采用强大的加密软件来确保敏感信息的安全。2024年&#xff0c;企业数据安全需求日益增长&#xff0c;各类加密软件应运而生&#xff0c;提…

收银系统源码-收银台(exe、apk安装包)自由灵活操作简单!

收银系统现在已经成为门店经营必备工具&#xff0c;尤其是连锁多门店。一套好的收银系统可以帮助门店管理门店、管理商品、管理会员等&#xff0c;可以更好的经营决策。线下收银端更是门店每天高频使用的。但线下收银端需要具备哪些亮点才能更加高效呢&#xff1f; 1.收银端支…

CPP/C语言中的位运算

背景: leetcode 汉明距离问题看题解时&#xff0c;遇见了这个问题这里简单总结一下。 实例: 异或运算符 ^ 会将左右两边的数据转化为二进制形式&#xff0c;按位与 4\^1100\^0011015或运算 | 同样将左右两边数据转化为二进制形式&#xff0c;按位或 4|1100|0011015与运算…

数据结构之红黑树的 “奥秘“

目录&#xff1a; 一.红黑树概念 二. 红黑树的性质 三.红黑树的实现 四.红黑树验证 五.AVL树和红黑树的比较 一.红黑树概念 1.红黑树&#xff0c;是一种二叉搜索树&#xff0c;但在每个结点上增加一个存储位表示结点的颜色&#xff0c;可以是Red或Black。 通过对任何 一条从根…

03 Flask-添加配置信息

回顾之前学习的内容 02 Flask-快速上手 Flask 中最简单的web应用组成 1. 导入核心库 Flask from flask import Flask2. 实例化 web应用 注意&#xff1a;不要漏了 app Flask(__name__) 中的 __name__ 表示&#xff1a;是从当前的py文件实例化 app Flask(__name__)3. 创…

内网隧道:端口转发

目录 LCX端口转发 场景一 场景二 SSH的端口转发 一、本地转发&#xff08;正向访问A&#xff09;&#xff1a; 二、远程转发&#xff08;反向访问A&#xff09; 三.NETSH端口转发 端口转发和端口映射 端口转发,有时被称为做隧道,是安全壳( SSH)为网络安全通信使用的一种方…

视频监控接入平台web客户端有时无法登录,有时打开实时视频出现黑屏的问题解决

目录 一、背景说明 二、解决过程 1、问题产生 2、命令介绍 ①基本用法 ②常用选项 ③示例 3、问题解决 三、最终结果 一、背景说明 在本地登录视频监控平台的服务器进行测试时&#xff0c;发现客户端登录不上。 检查服务器的服务和数据库&#xff0c;运行状况正常&#xff0c…

45个图源二维码分享及使用方法

我们曾在《40个图源二维码分享及使用方法》一文中&#xff0c;为你分享了40个图源二维码。 现在在此基础之上新增5个图源二维码&#xff0c;共分享45个。 如果你需要这些图源&#xff0c;请在文末查看领取方式。 45个图源 打开下面的网址进入水经微图&#xff08;简称“微图…

Swift 创建扩展(Extension)

类别(Category) 和 扩展(Extension) 的 用法很多. 常用的 扩展(Extension) 有分离代码和封装模块的功能,例如登陆页面有注册功能,有登陆功能,有找回密码功能,都写在一个页面就太冗余了,可以考虑使用 扩展(Extension) 登陆页面的方法来分离代码 本文介绍Swift 如何创建扩展(Ex…

maven项目下使用Jacoco测试覆盖率

【本文前提是了解maven项目及其Pom.xml机制&#xff0c;不熟悉可以看该博客的0.Pre部分Auto-Unit-Test-Case-Generator -- java项目自动测试生成-CSDN博客】 JaCoCo&#xff08;Java Code Coverage&#xff09;是一个开源的代码覆盖率工具&#xff0c;专门用于测量 Java 应用程…

Web测试中如何简单定位Bug

定位bug之前要确定自己对用例的理解是否有问题。&#xff08;在工作中,很多测试结果错误都是因为自己对用例的理解没有到位&#xff0c;以致于操作错误导致结果不符合预期&#xff09; 一般来说bug分为前端bug和后端bug&#xff0c;前端bug为请求数据错误&#xff0c;后端bug为…

web基础之信息泄露

1、目录遍历漏洞 &#xff08;1&#xff09;原理&#xff1a;本质是没有过滤用户输入的 ../ 相关的目录跳转符&#xff0c;使得攻击者通过目录跳转符来遍历服务器中的任意文件。 &#xff08;2&#xff09;题解&#xff1a; eg:根据提示遍历网页目录信息&#xff0c;会在某一个…

vscode---snippets配置全局代码片段,快捷开发!

代码片段的作用&#xff1a;在开发一个项目时&#xff0c;经常会遇到好多同一个代码逻辑&#xff0c;可配置固顶逻辑的代码块&#xff0c;避免重复敲同一代码&#xff1b; 举例&#xff1a;比如跳转登录&#xff0c;需要调用app的客户端方法&#xff0c;api调用跳转&#xff1…

Web 原生组件化方案:Web Components

你好&#xff0c;我是沐爸&#xff0c;欢迎点赞、收藏、评论和关注。 Web 组件化是一种将Web应用的UI部分拆分成可复用的独立组件的架构方法。这种方法有助于提高代码的可维护性、可重用性和可测试性。 而Web Components 标准则提供了一套原生的API&#xff0c;允许开发者创建…

TestCraft - GPT支持的测试想法生成器和自动化测试生成器

在当今快速变化的软件开发世界中&#xff0c;自动化测试已成为确保软件质量的关键环节。而随着AI技术的进步&#xff0c;越来越多的工具开始引入人工智能&#xff0c;来辅助生成测试用例和自动化测试脚本。其中&#xff0c;TestCraft&#xff0c;作为一款GPT支持的测试想法生成…

天命所归,SyntaxFlow助大圣取得真经

之前预告许久的SyntaxFlow功能已经登陆Yakit&#xff01; SyntaxFlow代码查询需要先进行项目编译。 手动编译 在前端的YakRunner界面&#xff0c;主界面或选项栏可以直接点击“编译项目”功能。 可见图中红色方框圈起的选项 编译项目的选项如下&#xff1a;必选项为项目名、…

工控机防病毒/防勒索病毒如何一步搞定?

随着勒索病毒的肆虐和内部运营泄密事件的频发&#xff0c;企业数据安全正面临着前所未有的挑战。苏州深信达网络科技有限公司&#xff0c;作为数据安全解决方案的先驱&#xff0c;推出了MCK主机加固解决方案&#xff0c;为企业数据安全提供了一道坚不可摧的防线。 MCK主机加固…

Linux:多路转接 select、poll、epoll

目录 1&#xff1a;select 1. 参数解释 2. 函数返回值 3. fd_set 4. fd_set 相关接口 5. timeval 5. 常见使用 6. 理解 select 执行过程 7. select 的特点 8. select 缺点 9. select 应用 2&#xff1a;socket 就绪条件 1. 读事件就绪&#xff08;Readable&#x…

智能优化算法-海马优化算法(SHO)(附源码)

目录 1.内容介绍 2.部分代码 3.实验结果 4.内容获取 1.内容介绍 海马优化算法 (Seahorse Optimization Algorithm, SHO) 是一种基于群体智能的元启发式优化算法&#xff0c;它模拟了海马的觅食行为、繁殖行为以及社会互动&#xff0c;用于解决复杂的优化问题。 SHO的工作机制…

精选干货!分享5款ai智能写论文软件

在当今信息爆炸的时代&#xff0c;AI智能写作工具已经成为我们写作过程中的得力助手。特别是对于学术论文的撰写&#xff0c;这些工具不仅能够提高写作效率&#xff0c;还能帮助用户生成高质量的文稿。以下是五款值得推荐的AI智能写论文软件&#xff0c;其中特别推荐千笔-AIPas…