# 插件

以本项目 wheatear (opens new window) 为例

# 导航菜单排序

目的

将现有的根据 nav-sort.json 对目录树排序的操作进行可视化处理

同时为目录/文件排序提供一个可用的 node 服务器

因为 vuepress 本身在 dev 下已经使用了 webpack-dev-server 作为开发环境的服务器

所以考虑直接使用该 server 而不是再新建一个

# 开发插件

新建 docs/.vuepress/plugins/ 目录用于存放插件

在目录下新建插件目录 docs/.vuepress/plugins/nav-sort-server

在 nav-sort-server 目录下新建 index.js

const fse = require("fs-extra");
const path = require("path");
const bodyParser = require("body-parser");

const standardResponse = (isSuccess, msgCode, response) => ({
  resultCode: isSuccess ? 1 : 0,
  data: isSuccess ? response || null : null,
  msg:
    {
      1: "成功",
      2: "失败"
    }[msgCode] || "没有消息"
});

const targetDirectory = `${process.cwd()}/docs/zh/.resources`;
const navTreePath = `${targetDirectory}/nav-dev.json`;
const navSortPath = `${targetDirectory}/nav-sort.json`;

module.exports = (options, ctx) => {
  return {
    beforeDevServer(app, server) {
      /**
       * 基于 express
       * @see http://www.expressjs.com.cn/4x/api.html
       */

      // for parsing application/json
      app.use(bodyParser.json());
      // for parsing application/x-www-form-urlencodedƒ
      app.use(bodyParser.urlencoded({ extended: true }));

      /* 页面 */

      // 页面 导航菜单配置
      app.get("/page/nav", function(req, res) {
        // res.sendFile(`${process.cwd()}/docs/.vuepress/plugins/nav-sort-server/pages/navsort/index.html`);
        // 更好的路径获取方案
        res.sendFile(path.resolve(__dirname, "pages/navsort/index.html"));
      });

      /* 服务 */

      // 获取 导航菜单
      app.get("/serve/nav/tree", function(req, res) {
        const nav = fse.readFileSync(navTreePath, "utf-8");
        res.json(standardResponse(true, 1, nav));
      });
      // 更新 导航菜单
      app.post("/serve/nav/tree", function(req, res) {
        const { data } = req.body;
        fse.writeFileSync(navTreePath, JSON.stringify(data));
        res.json(standardResponse(true, 1));
      });

      // 获取 导航菜单排序
      app.get("/serve/nav/sort", function(req, res) {
        const navsort = fse.readFileSync(navSortPath, "utf-8");
        res.json(standardResponse(true, 1, navsort));
      });
      // 更新 导航菜单排序
      app.post("/serve/nav/sort", function(req, res) {
        const { data } = req.body;
        fse.writeFileSync(navSortPath, JSON.stringify(data));
        res.json(standardResponse(true, 1));
      });
    }
  };
};

参考 beforeDevServer (opens new window)

# 引入插件

在 docs/.vuepress/config.js 中添加

plugins: [
  [require('./plugins/nav-sort-server')],
],

# 配置页面

因为 vuepress 是 Vue 驱动的静态网站生成器,所以考虑直接使用 vue 编写页面

至于如何引入 vue 文件,可以往 webpack-dev-server 及 Markdown 转 Vue 的中间过程方向思考

取巧的是,本项目使用了 vuepress 的默认主题,根据 特定页面的自定义布局 (opens new window) 可以实现快速引入

这里采用 设置 作为这一类功能的目录

新建 docs/zh/设置/导航菜单排序.md

---
layout: Navsort
---

新建 docs/.vuepress/components/Navsort.vue

依据默认主题的 Layouts.vue 改写,保留了 Sidebar 和 Navbar

<template>
  <div
    class="theme-container"
    :class="pageClasses"
    @touchstart="onTouchStart"
    @touchend="onTouchEnd"
  >
    <Navbar v-if="shouldShowNavbar" @toggle-sidebar="toggleSidebar" />

    <div class="sidebar-mask" @click="toggleSidebar(false)"></div>

    <Sidebar :items="sidebarItemsPlus" @toggle-sidebar="toggleSidebar">
      <slot name="sidebar-top" slot="top" />
      <slot name="sidebar-bottom" slot="bottom" />
    </Sidebar>

    <!-- 通过类名使用默认主题的样式 -->
    <main class="page">
      <!-- 通过类名使用默认主题的样式 -->
      <div class="theme-default-content content__default">
        <h1>导航菜单排序</h1>

        <section>
          <el-input placeholder="输入关键字进行过滤" v-model="filterText">
          </el-input>
        </section>

        <section class="badge-desc">
          <span v-for="badge in badges" :key="badge.type">
            <el-badge :type="badge.type" is-dot> </el-badge>
            {{ badge.desc }}
          </span>

          <span class="sort-info">
            <span>未排序个数:{{ unsortedLinksLength }}</span>
            <span>排序已改变个数:{{ changedLinksLength }}</span>
            <el-button
              @click="handleSave"
              :disabled="
                changedLinksLength === 0 && unsortedLinksLength.length === 0
              "
              type="primary"
              circle
              size="mini"
              ></el-button
            >
          </span>
        </section>

        <section>
          <el-tree
            :data="navTree"
            node-key="link"
            default-expand-all
            draggable
            @node-drag-end="handleDragEnd"
            icon-class="el-icon-s-flag"
            :filter-node-method="filterNode"
            :props="defaultProps"
            :allow-drop="allowDrop"
            :allow-drag="allowDrag"
            ref="tree"
          >
            <span class="custom-tree-node" slot-scope="{ node, data }">
              <span
                class="custom-tree-node-text"
                :class="`custom-tree-node-text-${sortType(data.link)}`"
              >
                {{ data.originName }}
              </span>
              <el-badge :type="sortType(data.link)" is-dot> </el-badge>
            </span>
          </el-tree>
        </section>
      </div>
    </main>
  </div>
</template>

<script>
import Navbar from "@parent-theme/components/Navbar.vue";
import Sidebar from "@parent-theme/components/Sidebar.vue";
import { resolveSidebarItems } from "@theme/util";

import axios from "axios";
import qs from "qs";
// 引入 element-ui
import { Button, Tree, Badge, Input, Message } from "element-ui";
import "element-ui/lib/theme-chalk/index.css";

export default {
  components: {
    Sidebar,
    Navbar,
    [Button.name]: Button,
    [Tree.name]: Tree,
    [Badge.name]: Badge,
    [Input.name]: Input
  },

  data() {
    return {
      isSidebarOpen: false,

      defaultProps: {
        children: "children",
        label: "originName"
      },

      badges: [
        {
          type: "success",
          desc: "已排序"
        },
        {
          type: "warning",
          desc: "排序已改变"
        },
        {
          type: "info",
          desc: "未排序"
        }
      ],

      navTree: [],
      navSort: [],

      originNavTreeSort: [],

      filterText: ""
    };
  },

  computed: {
    shouldShowNavbar() {
      const { themeConfig } = this.$site;
      const { frontmatter } = this.$page;
      if (frontmatter.navbar === false || themeConfig.navbar === false) {
        return false;
      }
      return (
        this.$title ||
        themeConfig.logo ||
        themeConfig.repo ||
        themeConfig.nav ||
        this.$themeLocaleConfig.nav
      );
    },

    shouldShowSidebar() {
      const { frontmatter } = this.$page;
      return (
        !frontmatter.home &&
        frontmatter.sidebar !== false &&
        this.sidebarItems.length
      );
    },

    sidebarItems() {
      return resolveSidebarItems(
        this.$page,
        this.$page.regularPath,
        this.$site,
        this.$localePath
      );
    },

    pageClasses() {
      const userPageClass = this.$page.frontmatter.pageClass;
      return [
        {
          "no-navbar": !this.shouldShowNavbar,
          "sidebar-open": this.isSidebarOpen,
          "no-sidebar": !this.shouldShowSidebar
        },
        userPageClass
      ];
    },

    /**
     * 给 sidebarItems 赋名称
     * 因为 YAML front matter 未获取到一级标题
     */
    sidebarItemsPlus() {
      if (this.sidebarItems.length > 0) {
        this.sidebarItems[0].children.forEach(x => {
          x.title = /\/([^/]+)\.html/.exec(x.path)[1];
        });
      }
      return this.sidebarItems;
    },

    changedLinksLength() {
      return this.originNavTreeSort.filter((x, i) => {
        return i !== this.currentNavTreeSort.findIndex(y => y === x);
      }).length;
    },

    unsortedLinksLength() {
      return this.currentNavTreeSort.length - this.navSort.length;
    },

    currentNavTreeSort() {
      const newSort = [];
      const recursion = array => {
        array.forEach(x => {
          newSort.push(x.link);
          if (Array.isArray(x.children) && x.children.length > 0) {
            recursion(x.children);
          }
        });
      };
      recursion(this.navTree);
      return newSort;
    }
  },

  watch: {
    filterText(val) {
      this.$refs.tree.filter(val);
    }
  },

  mounted() {
    this.$router.afterEach(() => {
      this.isSidebarOpen = false;
    });

    this.init();
  },

  methods: {
    toggleSidebar(to) {
      this.isSidebarOpen = typeof to === "boolean" ? to : !this.isSidebarOpen;
    },

    onTouchStart(e) {
      this.touchStart = {
        x: e.changedTouches[0].clientX,
        y: e.changedTouches[0].clientY
      };
    },

    onTouchEnd(e) {
      const dx = e.changedTouches[0].clientX - this.touchStart.x;
      const dy = e.changedTouches[0].clientY - this.touchStart.y;
      if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 40) {
        if (dx > 0 && this.touchStart.x <= 80) {
          this.toggleSidebar(true);
        } else {
          this.toggleSidebar(false);
        }
      }
    },

    DefaultValues() {
      return {
        originNavTreeSort: []
      };
    },

    init() {
      Promise.all([this.getNavTree(), this.getNavSort()])
        .then(() => {
          this.originNavTreeSort = JSON.parse(
            JSON.stringify(this.DefaultValues().originNavTreeSort)
          );
          const recursion = array => {
            array.forEach(x => {
              this.originNavTreeSort.push(x.link);
              if (Array.isArray(x.children) && x.children.length > 0) {
                recursion(x.children);
              }
            });
          };
          recursion(this.navTree);
        })
        .catch(() => {});
    },

    async getNavTree() {
      return axios
        .get("/serve/nav/tree")
        .then(res => {
          const { data } = res.data;
          this.navTree = JSON.parse(data);
        })
        .catch(() => {
          Message.error("获取【导航菜单树】失败");
        });
    },

    async getNavSort() {
      return axios
        .get("/serve/nav/sort")
        .then(res => {
          const { data } = res.data;
          this.navSort = JSON.parse(data);
        })
        .catch(() => {
          Message.error("获取【导航菜单排序】失败");
        });
    },

    async setNavTree(tree) {
      return axios({
        url: "/serve/nav/tree",
        method: "post",
        headers: {
          "Content-Type": "application/json;charset=UTF-8"
        },
        data: { data: tree }
      })
        .then(() => {})
        .catch(() => {
          Message.error("设置【导航菜单树】失败");
        });
    },

    async setNavSort(sort) {
      return axios({
        url: "/serve/nav/sort",
        method: "post",
        headers: {
          "Content-Type": "application/json;charset=UTF-8"
        },
        data: { data: sort }
      })
        .then(res => {
          const { resultCode } = res.data;
          if (resultCode === 1) {
            this.init();
          }
        })
        .catch(() => {
          Message.error("设置【导航菜单排序】失败");
        });
    },

    handleSave() {
      Promise.all([
        this.setNavTree(this.navTree),
        this.setNavSort([...this.currentNavTreeSort])
      ])
        .then(() => {})
        .catch(() => {});
    },

    filterNode(value, data) {
      if (!value) return true;
      return data.link.indexOf(value) !== -1;
    },

    sortType(link) {
      const originIndex = this.originNavTreeSort.findIndex(x => x === link);
      const currentIndex = this.currentNavTreeSort.findIndex(x => x === link);
      if (originIndex === currentIndex) {
        if (this.navSort.includes(link)) {
          // 已排序
          return "success";
        } else {
          // 未排序
          return "info";
        }
      }
      // 排序已改变
      return "warning";
    },

    handleDragEnd(draggingNode, dropNode, dropType, ev) {
      // 拖拽放置未成功
      if (dropType === "none") return;
    },

    allowDrop(draggingNode, dropNode, type) {
      // 不能改变菜单层级
      if (type === "inner") return false;
      // 只能改变同层级前后顺序
      return draggingNode.parent === dropNode.parent;
    },

    allowDrag(draggingNode) {
      return true;
    }
  }
  // end
};
</script>

<style scoped>
.theme-default-content section {
  margin: 10px 0;
}

.badge-desc {
  font-size: 12px;
  color: #aaa;
}

.badge-desc .el-badge {
  top: 2px;
  margin-left: 8px;
}

.sort-info {
  margin-left: 10px;
}

.sort-info > span {
  margin-right: 10px;
}

.custom-tree-node {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-size: 14px;
  padding-right: 8px;
}
.custom-tree-node-text {
  padding: 2px 3px;
}
.custom-tree-node-text-warning {
  color: #fff;
  background-color: #e6a23c;
}
.custom-tree-node-text-info {
  color: #fff;
  background-color: #909399;
}
</style>

提示

在 docs/zh/.productionignore 中添加 "设置"

以在生产环境下隐藏设置目录 详见【忽略文件

# 注意事项

因为服务中是通过 nav-dev.json 获取的目录树

而非通过分析目录结构(在 docs/.vuepress/nav/index.js 中实现,并最终写入 nav-dev.json)获得

因此当开发环境启动后的目录变化比如添加了一个文件,不会在 设置/导航菜单排序 的页面中体现

同样的,该变化也不会在页面的导航栏或侧边栏中体现

这时,重新启动开发环境即可(会重新分析目录结构,生成 nav-dev.json)

另,本方案可能不是最好的方案,不过可以作为解决方案之一,也相对适合本项目的现有进度

# 最近更新列表

目的

提供一个按更新时间倒序排列的文章快速入口

因为已有官方插件 last-update (opens new window)

所以参考其源码 node_modules/@vuepress/plugin-last-updated/index.js

# 开发插件

新建 docs/.vuepress/plugins/last-updated-files/index.js

const path = require("path");
const spawn = require("cross-spawn");
const { includesFiles } = require("../../nav/zh");

const lastUpdatedOfAllFiles = {};

module.exports = (options = {}, context) => ({
  extendPageData($page) {
    const timestamp = getGitLastUpdatedTimeStamp($page._filePath);
    if (includesFiles.includes($page.relativePath) && timestamp) {
      lastUpdatedOfAllFiles[$page.relativePath] = {
        title: $page.title || /\/([^/]+)\.md/.exec($page.relativePath)[1],
        path: $page.path,
        timestamp
      };
    }
    $page.lastUpdatedOfAllFiles = lastUpdatedOfAllFiles;
  }
});

function getGitLastUpdatedTimeStamp(filePath) {
  let lastUpdated;
  try {
    lastUpdated =
      parseInt(
        spawn
          .sync("git", ["log", "-1", "--format=%at", path.basename(filePath)], {
            cwd: path.dirname(filePath)
          })
          .stdout.toString("utf-8")
      ) * 1000;
  } catch (e) {
    /* do not handle for now */
  }
  return lastUpdated;
}

为了区分开发环境和生产环境

修改 docs/.vuepress/nav/index.js

// ... some code
let [excludesFiles, ignoreList, sortList] = [[], [], []];

const includesFiles = [];

// ... some code

if (dirent.isFile()) {
  includesFiles.push(`${path}/${dirent.name}`);
  if (dirent.name === "README.md") {
    file.text = "";
    file.pathName = "";
    hasReadme = true;
  } else {
    file.text = getFileName(dirent);
  }
}

// ... some code

// getSort 的返回值修改为
return Object.assign(func(currentDirectoryPath), { includesFiles });

修改 docs/.vuepress/nav/zh.js 为

const { getDirectoryFiles, getNav, getSidebar } = require("./index");

const { files, includesFiles } = getDirectoryFiles("zh");
const nav = getNav(files);
const sidebar = getSidebar(files);

// 可以在这里再次进行处理

module.exports = {
  nav,
  sidebar,
  includesFiles
};

# 引入插件

在 docs/.vuepress/config.js 中添加

plugins: [
  [require('./plugins/last-updated-files')],
],

# 使用插件

考虑直接在首页上展示

详见【首页】代码

# 优化

# 2020.03.03

添加最近发布列表

-- 将 git log 的第一条记录的时间作为文件的发布时间

优化最近更新列表规则

-- 将更新时间和发布时间相等的排除

主要代码

docs/.vuepress/plugins/last-updated-files/index.js 中新增

function getGitFirstTrackedTimeStamp(filePath) {
  let firstTracked;
  try {
    // 1565602676\n1565548863\n1565539297\n
    const tracked = spawn
      .sync("git", ["log", "--format=%at", path.basename(filePath)], {
        cwd: path.dirname(filePath)
      })
      .stdout.toString("utf-8")
      .split("\n");
    firstTracked = parseInt(tracked[tracked.length - 2]) * 1000;
  } catch (e) {
    /* do not handle for now */
  }
  return firstTracked;
}

完整代码

const path = require('path')
const spawn = require('cross-spawn')
const { includesFiles } = require('../../nav/zh')

const lastUpdatedOfAllFiles = {};

module.exports = (options = {}, context) => ({
  extendPageData($page) {
    const createTimestamp = getGitFirstTrackedTimeStamp($page._filePath)
    const timestamp = getGitLastUpdatedTimeStamp($page._filePath)
    if (includesFiles.includes($page.relativePath) && timestamp) {
      lastUpdatedOfAllFiles[$page.relativePath] = {
        title: $page.title || /\/([^/]+)\.md/.exec($page.relativePath)[1],
        path: $page.path,
        timestamp,
        createTimestamp,
      }
    }

    $page.createTimestamp = createTimestamp;

    // 只有 home 页用到
    if ($page.path === '/') {
      $page.lastUpdatedOfAllFiles = lastUpdatedOfAllFiles;
    }
  }
})

function getGitLastUpdatedTimeStamp(filePath) {
  let lastUpdated
  try {
    lastUpdated = parseInt(spawn.sync(
      'git',
      ['log', '-1', '--format=%at', path.basename(filePath)],
      { cwd: path.dirname(filePath) }
    ).stdout.toString('utf-8')) * 1000
  } catch (e) { /* do not handle for now */ }
  return lastUpdated
}

function getGitFirstTrackedTimeStamp(filePath) {
  let firstTracked
  try {
    // 1565602676\n1565548863\n1565539297\n
    const tracked = spawn.sync(
      'git',
      ['log', '--format=%at', path.basename(filePath)],
      { cwd: path.dirname(filePath) }
    ).stdout.toString('utf-8').split('\n')
    firstTracked = parseInt(tracked[tracked.length - 2]) * 1000
  } catch (e) { /* do not handle for now */ }
  return firstTracked
}

在 docs/.vuepress/theme/components/Home.vue 中增加最近发布列表

完整代码

<template>
  <main class="home"
    aria-labelledby="main-title">

    <section class="main-content">
      <!-- hero -->
      <header class="hero">
        <img v-if="data.heroImage"
          :src="$withBase(data.heroImage)"
          :alt="data.heroAlt || 'hero'">

        <h1 v-if="data.heroText !== null"
          id="main-title">{{ data.heroText || $title || 'Hello' }}</h1>

        <p class="description">
          {{ data.tagline || $description || 'Welcome' }}
        </p>

        <p class="action"
          v-if="data.actionText && data.actionLink">
          <NavLink class="action-button"
            :item="actionLink" />
        </p>
      </header>

      <!-- 最近发布列表 最近更新列表 -->
      <div class="last-updated-files">
        <h3>最近发布</h3>
        <div class="links">
          <div class="link"
            v-for="(link, index) in lastCreatedFiles"
            :key="index">
            <span>
              {{moment(link.createTimestamp).format('YYYY年MM月DD日')}}
            </span>
            <i class="el-icon-d-arrow-right"></i>
            <el-link :href="link.path"
              :underline="false">
              {{`${link.title}`}}
            </el-link>
          </div>
        </div>

        <h3>最近更新</h3>
        <div class="links">
          <div class="link"
            v-for="(link, index) in lastUpdatedFiles"
            :key="index">
            <span>
              {{moment(link.timestamp).format('YYYY年MM月DD日')}}
            </span>
            <i class="el-icon-d-arrow-right"></i>
            <el-link :href="link.path"
              :underline="false">
              {{`${link.title}`}}
            </el-link>
          </div>
        </div>
      </div>
    </section>

    <section class="features"
      v-if="data.features && data.features.length">
      <div class="feature"
        v-for="(feature, index) in data.features"
        :key="index">
        <h2>{{ feature.title }}</h2>
        <p v-for="(detail, detailIndex) in feature.details"
          :key="detailIndex">{{ detail }}</p>
      </div>
    </section>

    <Content class="theme-default-content custom" />

    <!-- live2d -->
    <section class="live2d-container">
      <live2d class="live2d"></live2d>
    </section>

    <div class="footer"
      v-if="data.footer">
      {{ data.footer }}
    </div>
  </main>
</template>

<script>
import NavLink from '@parent-theme/components/NavLink.vue';
import {
  Icon,
  Link,
} from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';

import moment from 'moment';
import live2d from '@theme/components/live2d.vue'

export default {
  components: {
    NavLink,
    [Icon.name]: Icon,
    [Link.name]: Link,
    live2d,
  },

  computed: {
    data() {
      return this.$page.frontmatter
    },

    actionLink() {
      return {
        link: this.data.actionLink,
        text: this.data.actionText
      }
    },

    lastCreatedFiles() {
      return this.$page.lastUpdatedOfAllFiles
        ? Object.values(this.$page.lastUpdatedOfAllFiles)
          .sort((a, b) => b.createTimestamp - a.createTimestamp)
          .slice(0, 10)
        : []
    },

    lastUpdatedFiles() {
      return this.$page.lastUpdatedOfAllFiles
        ? Object.values(this.$page.lastUpdatedOfAllFiles)
          .filter((x) => x.timestamp !== x.createTimestamp)
          .sort((a, b) => b.timestamp - a.timestamp)
          .slice(0, 5)
        : []
    }
  },

  data() {
    return {
      moment,
    }
  }
}
</script>

<style lang="stylus" scope>
.home
  padding: $navbarHeight 2rem 0
  max-width: 960px
  margin: 0px auto
  display: block

.main-content
  display: flex
  flex-wrap: wrap
  align-items: flex-start
  align-content: stretch
  justify-content: space-around

.hero
  text-align: center

  img
    max-width: 100%
    max-height: 280px
    display: block
    margin: 3rem auto 1.5rem
    border-radius: 50%
    box-shadow: 0 10px 5px #999

  h1
    font-size: 3rem

  h1, .description, .action
    margin: 1.8rem auto

  .description
    max-width: 35rem
    font-size: 1.6rem
    line-height: 1.3
    color: lighten($textColor, 40%)

  .action-button
    display: inline-block
    font-size: 1.2rem
    color: #fff
    background-color: $accentColor
    padding: 0.8rem 1.6rem
    border-radius: 4px
    transition: background-color 0.1s ease
    box-sizing: border-box
    border-bottom: 1px solid darken($accentColor, 10%)

    &:hover
      background-color: lighten($accentColor, 10%)

.last-updated-files
  margin-top: 0rem
  margin-left: 2rem

  h3
    margin: 2rem 0 0.5rem

  .links
    max-height: 360px
    overflow: auto

    .link
      user-select: none
      padding: 2px 0

      > span
        display: inline-block
        width: 100px
        font-size: 12px
        color: #909399

      > i
        font-size: 12px
        color: #909399
        margin-right: 5px

.features
  border-top: 1px solid $borderColor
  padding: 1.2rem 0
  margin-top: 2.5rem
  display: flex
  flex-wrap: wrap
  align-items: flex-start
  align-content: stretch
  justify-content: space-between

.live2d-container
  position: fixed
  right: 0px
  bottom: 20px
  width: 380px
  height: 200px

.feature
  flex-grow: 1
  flex-basis: 30%
  max-width: 30%

  h2
    font-size: 1.4rem
    font-weight: 500
    border-bottom: none
    padding-bottom: 0
    color: lighten($textColor, 10%)

  p
    color: lighten($textColor, 25%)

    &:last-of-type
      padding-right: 1rem
      text-align: right

.footer
  padding: 2.5rem
  border-top: 1px solid $borderColor
  text-align: center
  color: lighten($textColor, 25%)

@media (max-width: $MQMobile)
  .home
    .hero
      width: 100%

    .last-updated-files
      width: 100%
      margin-top: 0

    .features
      flex-direction: column

    .feature
      max-width: 100%
      padding: 0 2.5rem

@media (max-width: $MQMobileNarrow)
  .home
    padding-left: 1.5rem
    padding-right: 1.5rem

    .hero
      width: 100%

      img
        max-height: 210px
        margin: 2rem auto 1.2rem

      h1
        font-size: 2rem

      h1, .description, .action
        margin: 1.2rem auto

      .description
        font-size: 1.2rem

      .action-button
        font-size: 1rem
        padding: 0.6rem 1.2rem

    .last-updated-files
      width: 100%
      margin-top: 0

    .feature
      h2
        font-size: 1.25rem
</style>
发布时间: 2019-12-23 23:29:03
更新时间: 2021-03-10 13:42:24