将思维导图mind-map嵌入wordpress网站的全过程(持续更新)

问题起源

我想写小说,想要写大纲,所以想在网站实现思维导图的功能。
在git上找到了开源的思维导图:https://github.com/wanglin2/mind-map
它的界面看起来非常美好,但是好像打开保存的是本地的文件。我想加打开云端数据的按钮,它会弹出一个对话框,让用户选择数据库中当前账号下的思维导图,打开它。
保存也希望能保存到wp的数据库中。
这个思维导图的项目文件类型就是json,我想可以将json转化成字符串,直接保存成文章的内容。

实现过程

将思维导图嵌入页面

用container直接嵌入

git上的思维导图在打包后形成index.html和dist文件夹,dist文件夹下有一堆css和js。
我将index.html改写成php的样式,放在template文件夹下:
file
具体来说,在script前面加上:

<?php

/*
 * mindmap
 * @author 乌鸦
 * @date 2024
 * */
get_header();//加载顶部内容
$root = get_template_directory(); //主题路径
$url  = get_template_directory_uri();//主题url
?>
<main class="main-container no-sidebar">
    <div class="main-main">

在最后加上

</div>
</main>
<?php
get_footer();
?>

中间,由于index.html在引用css和js时,采用的都是绝对路径,例如:

  <link href="dist/css/chunk-vendors.css?88249d7b5b96dd589b2c" rel="stylesheet">
  <link href="dist/css/app.css?88249d7b5b96dd589b2c" rel="stylesheet">

我们需要把它改成相对路径,利用wordpress定义的get_template_directory_uri()函数,可以获取到主题所在的目录。这样上面的例子要改成:

<link href="<?php echo get_template_directory_uri(); ?>/template/page/dist/css/chunk-vendors.css?88249d7b5b96dd589b2c" rel="stylesheet">
<link href="<?php echo get_template_directory_uri(); ?>/template/page/dist/css/app.css?88249d7b5b96dd589b2c" rel="stylesheet">

然后,需要在主题的include/config.php中,声明这个新的思维导图页面模板:

const PAGES = [
    '文章聚合' => [
        'template'  => 'template/page/posts.php', //模板文件
        'dependent' => [
            /*
             * 依赖的样式
             * 可以使远程资源
             * */
            'styles' => [
                '/common/page/page.css'
            ],
            /*依赖的脚本*/
            'script' => [

            ]
        ]
    ],
    '留言板'   => [
        'template' => 'template/page/board.php',
    ],
    '思维导图'  => [
        'template' => 'template/page/mindmap.php',
        'dependent' => [
            /*
             * 依赖的样式
             * 可以使远程资源
             * */
            'styles' => [
                '/template/page/dist/css/app.css',
                '/template/page/dist/css/chunk-6d4e8018.css',
                '/template/page/dist/css/chunk-7babbe51.css',
                '/template/page/dist/css/chunk-vendors.css'
            ],
            /*依赖的脚本*/
            'script' => [
                '/template/page/dist/js/app.js',
            ]
        ]
    ]
];

css问题

嵌入以后发现css样式不对,顶部工具被挡住了,右键菜单的字体和颜色也不对。
file
这是因为模板调用的css优先级比样式表的css优先级要低,导致被样式表里的*开头的万用样式代替了。
因此,修改css样式表,在通用样式 *{} 的后面加上:

/* mindmap相关 */

.contextmenuContainer .item .desc[data-v-dbfd708e], .contextmenuContainer .item .name[data-v-dbfd708e] {
    font-size: 14px;
    font-family: PingFangSC-Regular,PingFang SC;
    font-weight: 400;
    color: #1a1a1a;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.contextmenuContainer .item.danger .name[data-v-dbfd708e] {
    color: #f56c6c;
}
.contextmenuContainer .item.disabled .name[data-v-dbfd708e] {
    color: grey;
}
.contextmenuContainer .item[data-v-dbfd708e] {
    position: relative;
    height: 28px;
    line-height: 28px;
    padding: 0 16px;
    cursor: pointer;
    display: flex;
    justify-content: space-between;
}
.contextmenuContainer .item [data-v-dbfd708e] {
    position: relative;
    height: 28px;
    line-height: 28px;
    padding: 0 16px;
    cursor: pointer;
    display: flex;
    justify-content: space-between;
}
.toolbarContainer .toolbar .toolbarBlock[data-v-00dc8188] {
    display: flex;
    background-color: #fff;
    padding: 10px 20px;
    border-radius: 6px;
    box-shadow: 0 2px 16px 0 rgba(0, 0, 0, .06);
    border: 1px solid rgba(0, 0, 0, .06);
    margin-right: 20px;
    flex-shrink: 0;
    padding-top: 60px !important;
}

样式就正常了。
file

vue2 nodejs 部署

参考 https://zhuanlan.zhihu.com/p/349318513
https://www.runoob.com/w3cnote/nvm-manager-node-versions.html
部署nodejs时需要注意版本,这个wanglin2/mind-map使用的是17以及以下的nodejs。
我采用了nvm管理nodejs的版本,选择了16的nodejs。
由于中间有个库的版本对不上,所以在npm i时要加上--legacy-peer-deps
为了方便测试,我先将wanglin2/mindmap部署在了本地,然后再npm run serve后,可以在http://localhost:8080 访问到思维导图的页面。

修改vue代码,添加几个按钮

wanglin2/mind-map中,采用了woff的图标。我在网上找了一个在线的工具,可以打开查看他提供的woff和ttf图标文件。
https://segmentfault.com/a/1190000020121850
在mind-map项目中,上方的按钮在Toolbar.vue中。在html部分加入:

    <div class="toolbar" ref="toolbarRef">
        <!-- 之前的组件 -->
        <!-- XXXXXXXXX -->
        <div class="line"></div>
        <div class="toolbarBtn" @click="$bus.$emit('showImportOnline')">
          <span class="icon iconfont iconwenjian"></span>
          <span class="text">{{ $t('toolbar.openFileOnline') }}</span>
        </div>
        <div class="toolbarBtn" @click="saveOnlineFile">
          <span class="icon iconfont iconwangzhan"></span>
          <span class="text">{{ $t('toolbar.saveSameFileOnline') }}</span>
        </div>
        <div class="toolbarBtn" @click="$bus.$emit('showExportOnline')">
          <span class="icon iconfont icondaochu1"></span>
          <span class="text">{{ $t('toolbar.saveFileOnline') }}</span>
        </div>
      </div>
    </div>
    <ImportOnline></ImportOnline>
    <ExportOnline></ExportOnline>

这里添加了一条竖线+3个按钮,其中在线导入、在线另存的功能需要加弹窗,而直接在线保存的功能不需要弹窗。
对于需要弹窗的在线导入导出功能,在scripts中加入:

import ImportOnline from './ImportOnline'
import ExportOnline from './ExportOnline'
export default {
  name: 'Toolbar',
  components: {
    ImportOnline,
    ExportOnline
  },

然后,新建ImportOnline.vue文件,内容为:

<template>
  <el-dialog
    class="nodeImportDialog"
    :title="$t('toolbar.openFileOnline')"
    :visible.sync="dialogVisible"
    width="40%"
  >
  <div class="popup">
    <ul class="articleList">
      <li class="articleItem" v-for="article in articles" :key="article.id" @click="selectArticle(article)">
        {{ article.title.rendered }}
      </li>
    </ul>
  </div>
  </el-dialog>
</template>

<script>
import axios from 'axios';
export default {
  name: 'ImportOnline',
  data() {
    return {
      dialogVisible: false,
      articles: []
    };
  },
  watch: {
    dialogVisible(val, oldVal) {
      if (val && !oldVal) {
        this.articles = []
        this.fetchArticles();
      }
    }
  },
  created() {
    this.$bus.$on('showImportOnline', this.handleShowImport)
  },
  beforeDestroy() {
    this.$bus.$off('showImportOnline', this.handleShowImport)
  },
  methods: {
    handleShowImport() {
      this.dialogVisible = true
    },
    getCookie() {
      // 从本地拿wordpress登陆时产生的jwttoken,token相关部分在本文中也有说明。
      const cookieName = 'jwt_token';
      // 获取 Cookie
      let reg = RegExp(cookieName + '=([^;]+)')
      let arr = document.cookie.match(reg)
      if (arr) {
        this.cookieValue =  arr[1]
        console.log(this.cookieValue)
      } else {
        this.cookieValue = document.cookie
      }
    },
    fetchArticles() {
      // 获取JWT令牌,假设你已经在登录时将令牌存储在本地存储中
      this.getCookie();
      const jwtToken = this.cookieValue;
      // 设置Axios的默认请求头,添加Authorization头部,携带JWT令牌
      axios.defaults.headers.common['Authorization'] = `Bearer ${jwtToken}`;
      const apiUrl = 'https://raven.zone/wp-json/wp/v2/posts';
      axios.get(apiUrl,{
        headers: {
          'Authorization': `Bearer ${jwtToken}`,
          'Content-Type': 'application/json',
        },
        params:{
          categories:33,
          status:'publish,private',
          per_page:'50'
        }
        })
        .then(response => {
            // 处理API响应
            this.token = jwtToken;
            this.result = response.data;
            this.articles = response.data;
          })
          .catch(error => {
            // 处理错误
            console.error(error);
          });
    },
    selectArticle(article) {
      // 处理用户选择文章的逻辑,可以打开文章或执行其他操作
      try {
        // 这是我定义的mindmap文章类型的格式,即将需要转成json的部分用[mindmap]xxx[/mindmap]围起来。
        const regex = /\[mindmap\](.*?)\[\/mindmap\]/
        const articleContent = article.content.rendered.match(regex)[1];
        // 直接读取的rendered会有转义,所以需要把它变回原来带有<>符号的字符串。json要用。
        const parser = new DOMParser();
        const decodedContent = parser.parseFromString(articleContent, "text/html").body.textContent;
        // 得到的思维导图数据data。
        let data = JSON.parse(decodedContent)
        if (typeof data !== 'object') {
          throw new Error(this.$t('import.fileContentError'))
        }
        // 这是王林的思维导图数据设置的函数。
        this.$bus.$emit('setData', data)
        this.$message.success(this.$t('import.importSuccess'))
        // 这是我定义的,将一些关于在线状态的设置参数传给其他函数使用。
        this.$store.commit('setIsHandleLocalFile', false)
        this.$store.commit('setIsHandleOnlineFile', true)
        this.$store.commit('setOnlineConfig', {
          token: this.cookieValue,
          ID: article.id,
          Title: article.title.rendered
        })
      } catch (error) {
        console.log(error)
        this.$message.error(this.$t('import.fileParsingFailed'))
      }
      this.cancel()
    },
    cancel() {
      this.dialogVisible = false
    }
  }
};
</script>
<!-- 一些css设置 -->
<style lang="less" scoped>
.nodeImportDialog {
  .popup{
    padding:0px
  }

  .articleList {
    padding:3px 10px 3px 10px;
    display: table;
    clear: both;
  }
  .articleItem{
    width:200px;
    display: block;
    float: left;
    margin: 5px 5px 5px 5px;
    background-color: rgb(255, 244, 230);
    list-style-type: none;
    border-radius: 5px;
    padding: 5px 5px 5px 5px;
    text-align: center;
    font-weight: bold;
    cursor:pointer
  }
}
</style>

对于另存数据,我在另存的窗口中加了输入标题的输入框和保存按钮。
新建ExportOnline.vue文件,template部分主要增加:

    <form class="saveArticleForm" @submit.prevent="saveArticle">
      <label class="saveArticleFormTitle" for="title">标题:</label>
      <input class="saveArticleFormInput" type="text" id="title" v-model="articleTitle" required>
      <button class="saveArticleFormSubmit" type="submit">确认</button>
    </form>

对应绑定的saveArticle函数内容:

    async saveArticle() {
      try {
        // 替换以下变量的值
        const apiURL = 'https://raven.zone/wp-json/wp/v2/posts';
        const jwtToken = this.cookieValue;

        // 设置请求头,包含JWT令牌
        const headers = {
          Authorization: `Bearer ${jwtToken}`,
          'Content-Type': 'application/json',
        };

        // 准备文章数据
        let data = getData();
        let string = JSON.stringify(data);
        const articleData = {
          title: this.articleTitle,
          // 可以添加其他文章相关的数据
          content: `[mindmap]${string}[/mindmap]`.replace(/[<>&"]/g,function(c){
            return {'<':'<','>':'>','&':'&','"':'"'}[c];
          }),
          status:'private',
          // date_gmt:this.utcTime, //wordpress会自动处理时间,所以不需要特别指定时间
          // date:this.localTime,
          categories: [33]

        };
        Notification.closeAll()
        Notification({
          title: this.$t('toolbar.tip'),
          message: this.$t('toolbar.savingOnlineFront') + this.articleTitle + this.$t('toolbar.savingOnlineEnd'),
          duration: 0,
          showClose: true
        })

        // 发送POST请求保存文章
        const response = await axios.post(apiURL, articleData, { headers });

        // 处理成功的响应,可以根据需要进行路由跳转或其他操作
        console.log('文章保存成功', response.data);
        this.$store.commit('setIsHandleOnlineFile', true)
        this.$store.commit('setOnlineConfig', {
          token: this.cookieValue,
          ID: response.data.id,
          Title: this.articleTitle
        })
        this.cancel();
      } catch (error) {
        // 处理错误
        console.error('保存文章失败', error);
      }
    }

回到最外层的Toolbar.vue文件,定义保存函数:

    async saveOnlineFile() {
      if(!this.$store.state.isHandleOnlineFile){
        <!--如果现在的状态不是正在编辑在线文件,就打开另存在线的弹窗。如果正在编辑在线,就直接保存-->
        this.$bus.$emit('showExportOnline')
      }else{
        let data = getData()
        const apiURL = `https://raven.zone/wp-json/wp/v2/posts/${this.$store.state.onlineConfig.ID}`;
        const jwtToken = this.$store.state.onlineConfig.token;

        // 设置请求头,包含JWT令牌
        const headers = {
          Authorization: `Bearer ${jwtToken}`,
          'Content-Type': 'application/json',
        };
        let string = JSON.stringify(data)
        const articleData = {
          // 可以添加其他文章相关的数据,这里只更新内容。
          content: `[mindmap]${string}[/mindmap]`.replace(/[<>&"]/g,function(c){
            return {'<':'<','>':'>','&':'&','"':'"'}[c];
          }),
        };
        // 发送POST请求保存文章
        const response = await axios.post(apiURL, articleData, { headers });

        // 处理成功的响应,可以根据需要进行路由跳转或其他操作
        console.log('文章保存成功', response.data);
        this.$message.success(this.$t('export.saveSuccess'))
      }
    },

对于这几个按钮的图标,我直接使用了王林的思维导图应用中没用到的几个图标。
最后的效果:
file

定义wordpress文章格式用于存储思维导图json数据

我尝试过使用meta data,但是不太方便,不如直接改到正文。
我在wordpress中新建了一个分类叫mindmap,约定了思维导图对应的json内容转成的字符串要被放在[mindmap][/mindmap]中间。
其他的没什么好说的了,上面的vue中也写好了的。

身份验证

wordpress REST API接口

wordpress提供了REST API接口,在其它应用中可以通过对特定链接发送http请求,获取信息。我基于该接口实现思维导图的读取 保存等功能。
相关文档地址:
https://developer.wordpress.org/rest-api/requests/
但是默认的REST API接口不带身份验证的功能,因此我在wordpress中安装了插件。
在wordpress安装插件的页面,可以搜到三个插件,分别是:

WP在登录时发送cookie

我采用了wordpress的JWT Auth插件用于发放和验证JWT令牌。
在配置时,需要在wordpress的.htaccess文件中添加:

RewriteEngine on
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule ^(.*) - [E=HTTP_AUTHORIZATION:%1]

同时,在wordpress的wp-config.php中添加:

define('JWT_AUTH_SECRET_KEY', '*****');
define('JWT_AUTH_CORS_ENABLE', true);

其中***是我的jwt令牌。
值得注意的是,JWT Auth的文档并没有说这一段要加在哪,所以我加在了最后。然而这样最后生成的token是无效的。
这一段要加在原本自有的Authorization相关密钥的旁边,在WP相关配置函数启动之前!!

同时我利用了wordpress的wp_login钩子,实现在wordpress登录时发放一个JWT令牌。

/* 乌鸦自定义 */
use JWTAuth\Auth;

// 自定义登录成功后的动作
function custom_login_success_action($user_login, $user) {
    // 生成JWT令牌
    echo '<script>';
    echo 'console.log("Hello from PHP!");';
    echo '</script>';

    // $user = wp_authenticate($username, $password);

    // if (is_wp_error($user)) {
    //     // 用户验证失败,处理逻辑(如果需要)

    //     return;
    // }

    // 用户验证成功,生成JWT令牌
    echo '<script> console.log(' . $user_login . ');</script>';
    $jwt_token = generate_jwt_token($user);
    echo '<script> console.log(' . $jwt_token. ');</script>';
    // $jwt_token = 'sfsffffffffjwtme';
    // echo '<script>';
    // echo 'console.log("jwt_token=' . $jwt_token . ');';
    // echo '</script>';

    // 将令牌发送给客户端(在这个例子中,通过JavaScript设置一个cookie)
    setcookie('jwt_token', $jwt_token, time() + 36000, '/', '', false, true);
    // echo '<script>document.cookie = "jwt_token=' . $jwt_token . '";</script>';

}
add_action('wp_login', 'custom_login_success_action', 10, 2);

// 生成JWT令牌
function generate_jwt_token($user) {
    // 使用插件提供的函数生成JWT令牌
    $pluginInstance = new Auth();
    $token = $pluginInstance->generate_token($user->ID);

    return $token;
}

域名问题

问题:发放令牌后,发现cookie中的jwt_token的domain是raven.zone,这意味着我在vue调试中使用的localhost无法访问到这个token,同时它的httponly被设为√。
所以一方面在测试时要把domain设置成localhost,另一方面要去掉httponly的标志。参考:
https://blog.csdn.net/weixin_40686603/article/details/116188863

在查找资料以后,了解到php的setcookie函数可以直接指定域名和httponly等标志,因此将setcookie一句改成:

setcookie('jwt_token', $jwt_token, time() + 36000, '/', 'raven.zone', false, false);

这样的话所有raven.zone的二级域名都能检索到该cookie,同时设置了httponly为false。
同时,可以通过改host等方法,将local.raven.zone解析成127.0.0.1,这样就可以用http://local.raven.zone:8889 访问到我本地的vue服务器,同时在这里可以拿到raven.zone发放的cookie。
我懒得改host,所以直接让dns服务器解析了local.raven.zone,效果也是一样的。
喔,还有,需要改vue的配置文件,不然访问不了local.raven.zone这个域名。具体代码待更新。

令牌报错

403 (Forbidden) -- JWT is not configurated properly, please contact the admin
https://github.com/Tmeister/wp-api-jwt-auth/issues/59

Rest API支持汇总

https://developer.wordpress.org/rest-api/requests/

vue应用提交请求

查阅文章

待更新

创建文章

待更新

修改文章

待更新