Directus无头CMS+Nuxt.js 实现文章管理系统

directus:是一个基于TS+vue3的Web数据管理系统,可以实现无头(Headless CMS),支持自定义模型,资源管理,提供REST API和GraphQL API.类似的还有striapi. 是JAMstack技术栈的APIS组件.

nuxt.js:是一个基于vue的web 框架.

JAMstack: Javascript+APIs+Markup

什么是JAMstack?:

JAM是大前端的一种开发技术栈,是Javascript+Apis+Markup的首字母缩写.

Javascript: 用JS编程语言.

Apis: 数据提供. 前后端分离的后端. 如,Strapi,directus

Markup: 视觉渲染组件. 如next.js,nuxt.js

无头CMS(Headless CMS)?

什么是无头CMS,以前CMS是包含视觉部分,所见所得. 现在应用开发方式经常使用前后端分离方式,有PC,H5,App,IoT等多端,内容管理要与视觉分离. 只有纯粹的数据.

directus?

directus 是一个界面清晰,操作简单,开源的数据管理平台.

由管理平台与Apis组成.减少开发重复工作量.

核心功能由:

  • 项目管理
  • 数据模型管理
  • 角色权限管理
  • 内容管理
  • 资源文件管理
  • 用户管理
  • webhook
  • 多语言翻译
  • 流程管理
  • 提供API

用户角色有:

  • 管理员用户,包含系统管理员,内容运营人员等
  • 普通会员,普通用户
  • 开发者,api调用和模型管理

官网: https://directus.io/

nuxt.js

nuxt.js 是基于vue,nodejs技术栈的,也可以使用react的next.js . 主要用于SSG服务端渲染,作SEO搜索引擎优化.

我们演示利用directus+nuxt.js 实现一个简单的文章管理系统.

提供:

  • 用户注册/登录/退出
  • 文章列表+文章详情
  • 阅读数
  • 多模块首页
  • 本地化

快速安装

安装directus

使用docker 运行

docker-compose.yaml

version: '3'
services:
  database:
    image: mysql:5.7
    container_name: directus_database
    ports:
      - "13306:3306"
    volumes:
      - ./data/mysql:/var/lib/mysql
      - ./conf/mysql:/etc/mysql/my.cnf
    # 启用真正root权限  
    privileged: true
    # 关闭安全难
    security_opt:
        - seccomp:unconfined
    command: 
      --character-set-server=utf8mb4 
      --collation-server=utf8mb4_unicode_ci
      --default-authentication-plugin=mysql_native_password
    environment:
      TZ: Asia/Shanghai
      MYSQL_ROOT_PASSWORD: 123456
      MYSQL_DATABASE: directus
      MYSQL_USER: directus
      MYSQL_PASSWORD: 123456
  cache:
    image: redis:6
    container_name: directus_cache

  directus:
    image: directus/directus:latest
    ports:
      - 8055:8055
    container_name: directus_app
    depends_on:
      - cache
      - database
    volumes:
      - ./uploads:/directus/uploads
      # 扩展
      - ./extensions:/directus/extensions
    environment:
      KEY: '255d861b-5ea1-5996-9aa3-922530ec40b1'
      SECRET: '6116487b-cda1-52c2-b5b5-c8022c45e263'
      ADMIN_EMAIL: 'admin@test.com'
      ADMIN_PASSWORD: '123456'
      WEBSOCKETS_ENABLED: true

      DB_CLIENT: 'mysql'
      DB_HOST: 'database'
      DB_PORT: '3306'
      DB_DATABASE: 'directus'
      DB_USER: 'directus'
      DB_PASSWORD: '123456'

      CACHE_ENABLED: 'true'
      CACHE_STORE: 'redis'
      REDIS: 'redis://cache:6379'
      
      # 跨域,方便调试
      CORS_ENABLED: true
      CORS_ORIGIN: '*'
      
      # 允许扩展
      EXTENSIONS_AUTO_RELOAD: true

运行:

# 先初始化mysql,再执行directus
docker-compose up database
docker-compose up -d

image-20230903213056077

登录界面: http://127.0.0.1:8055

directus管理登录界面

管理界面: 设置项目名和默认语言

image-20230903213451224

创建一个API调用那用户. 并创建token,以便后续访问.

image-20230903214655639

api 获取基本信息

http://127.0.0.1:8055/server/info

api获取基础

创建 nuxt.js 项目

https://nuxt.com.cn/docs/getting-started/installation

安装nodejs环境等省略...

nuxt脚手架

npx nuxi init nuxt-web
yarn 或者 npm install
npm run dev

image-20230904155736133

PS: 3000端口经常被占用,或被Service Workers占用.如果启动不了,查看Service Workers是否占用了3000端口.

修改端口

export default defineNuxtConfig({
  devtools: { enabled: false },
  devServer:{
      port: 3000
  }
})

安装directus SDK

npm install @directus/sdk

index.vue

<template>
  <div>
    index
    <h1>项目名: {{project.project_name }}</h1>
  </div>
</template>
<script setup>
import { createDirectus,rest,serverInfo } from '@directus/sdk';

const client = createDirectus('http://127.0.0.1:8055')
.with(rest());
const {project} =await client.request(serverInfo())
console.log(project)
</script>

image-20230904165020288

API文档:

directus支持openapi 协议,可以直接通过postman或者apifox 直接导入项目

http://127.0.0.1:8055/server/specs/oas?access_token=ZHnsXk-V-G4n6QgW3OXJt7ofwu0qdymR

image-20230904170441151

可以生成API文档

image-20230904170513111

基础工作完成. 后面通过正常项目使用.

CURD

通过一个产品管理的小例,熟悉CURD操作.

安装Nuxt directusSDK

yarn add nuxt-directus --dev

参考: https://www.nuxt-directus.site/getting-started/setup

配置nuxt.config.ts

export default defineNuxtConfig({
  devtools: { enabled: false },
  ssr:false,
  devServer:{
    port: 3000
  },
  // 添加module
  modules: ["nuxt-directus"], 
  directus: { 
     url: "http://127.0.0.1:8055/" 
  }
})

创建模型

创建一个product 的模型

image-20230904190100142

增加几个字段: title(string),price(string)

image-20230904190625179

image-20230904190836615

增加公开访问权限

image-20230904192305277

VUE模板

创建一个pages/product/index.vue

<template>
    <div class="title"><h1>产品列表</h1></div>
    <div class="container">
        <div>
            <button @click="createProdcut">添加产品</button>
        </div>
        <hr />
        <table width="100%" border="1" cellpadding="2" cellspacing="0">
            <thead>
                <tr key="0">
                    <th width="30" align="left">ID</th>
                    <th  align="left">标题</th>
                    <th width="80" align="left">价格</th>
                    <th width="150" align="left">更新时间</th>
                    <th width="80" align="center">操作</th>
                </tr>
            </thead>
            <tbody>
                <tr v-for="product in products" :key="product.id">
                    <td>{{ product.id }}</td>
                    <td>{{ product.title }}</td>
                    <td>{{ product.price }}</td>
                    <td>{{ product.date_created?.substring(0,16)}}</td>
                    <td>
                        <button @click="()=>{deleteProduct(product.id)}">删除</button>
                        <button @click="()=>{updateProduct(product.id)}">修改</button>
                    </td>
                </tr>
            </tbody>
        </table>
    </div>
</template>
<script setup lang="ts">
const { getItems,createItems,deleteItems,updateItem} = useDirectusItems();

interface Product {
    id?: number;
    title: string;
    price: string;
    date_created?:string;
}
const products:Ref<Product[]>=ref([]);


const newProducts: Product[] = [
    { title: "Banana", price: "10.02" },
];

// C创建
const createProdcut = async () => {
    const ret=await createItems<Product>({
        collection: "product",
        items: newProducts
    });
    await getProducts();
}

// R获取
const getProducts = async () => {
    products.value = await getItems<Product>({
        collection: "product"
    });
}


// U修改
const updateProduct = async (id:string) => {
    const product ={
        price: (Math.random()*10).toString(10).substring(0,4)
    }
    await updateItem({
        collection: "product",
        id,
        item: product
    })
    await getProducts();
}

// D删除
const deleteProduct=async (id:string) => {
    await deleteItems({
        collection: "product",
        items: [id]
    })
    await getProducts();
}

// 初始化
await getProducts();
</script>

效果:

image-20230904205010372

后台:

image-20230904205210031

注册界面

创建一个 /auth/register.vue 组件.

利用useDirectusAuthregister 接口,通过邮箱密码注册. 注册成功返回用户信息.

实际调用 POST /user

<template>
    <div class="container">
        <div class="header">
            <h1>注册</h1>
        </div>
        <div class="content">
            <div class="login">
                <input type="text" placeholder="请输入用户名" v-model="email">
                <input type="password" placeholder="请输入密码" v-model="password">
                <button @click="onSubmit">注册</button>
            </div>
        </div>
    </div>
</template>
<script setup lang="ts">
const { register } = useDirectusAuth();

const email = ref("");
const password = ref("");

const onSubmit = async () => {
  try {
   const newUser= await register({ 
        email: email.value, 
        password: password.value
    });
    alert("注册成功!");
    console.log(newUser);
  } catch (e) {}
};
</script>

注册界面

登录界面

由两个接口组成,登录接口和获取用户信息接口.

  1. POST /auth/login登录后获取得 access_token

image-20230904215144742

  1. GET /user/me 通过access_token 获得登录用户信息

auth获取用户信息

  1. 把登录状态 token 存放在cookie或者localstorage 里.

    cookie

创建/auth/login.vue

<template>
    <div class="container">
        <div class="header">
            <h1>登录</h1>
        </div>
        <div class="content">
            <div class="login">
                <input type="text" placeholder="请输入用户名" v-model="email">
                <input type="password" placeholder="请输入密码" v-model="password">
                <button @click="onSubmit">登录</button>
            </div>
        </div>
        <div class="user-info">
            <div style="margin-top: 55px" v-if="user"> 
                <h1>Current User</h1> 
                <pre>{{ user }}</pre> 
            </div> 
        </div>
    </div>
</template>
<script setup lang="ts">

const { login } = useDirectusAuth();
// 用户信息
const user = useDirectusUser(); 

const email = ref("");
const password = ref("");

const onSubmit = async () => {
    try {
        const ret=await login({ 
            email: email.value, 
            password: password.value 
        });
        console.log(ret);
        alert("登录成功")
    
    } catch (e) {
        console.log(e);
    }
};
</script>

退出登录

退出,调用/auth/logout ,清空本地用户信息.

 await $fetch("/auth/logout", {
    baseURL: baseUrl,
    body: { refresh_token: refreshToken.value },
    method: "POST"
  });

vue

<template>
<button @click="exit">退出</button>
</template>
<script setup lang="ts">
  const { logout } = useDirectusAuth();
  const exit = async () => {
    await logout();
  }
</script>

文章管理

创建模型,分类和文章

创建模型

Category 分类模型:

字段说明类型关联
name标题String
slug唯一标识UID

image-20230906033212979

Article 文章模型

字段说明类型关联
title标题String
slug唯一标识UID
image封面Media
content内容Text
category分类Relationcategory
seo搜索引擎
meta_titleseo标题String
meta_descriptionseo描述String
meta_keywordsseo关键字String

image-20230906033906667

创建页面

article.vue

<template>
    <div class="header">
        <h1>文章列表</h1>
    </div>
    <div class="container">
        <ul class="article-list">
            <li v-for="item in articles" :key="item.id">
                <NuxtLink :href="`/article/${item.slug}`">
                    <h2>{{ item.title }}</h2>
                </NuxtLink>
            </li>
        </ul>
    </div>
</template>
<script setup lang="ts">
const { getItems} = useDirectusItems();

interface Article {
    id: string;
    title: string;
    slug: string;
    content: string;
}
const articles:Ref<Article[]>=ref([]);

articles.value=await getItems<Article>({collection: "article"});
</script>

image-20230906050333607

详情页:

[slug].vue

<template>
    <div class="header">
        <h1>{{article.title }}</h1>
    </div>
    <article class="content">
        <div>
            <img :src="article.image?img(article.image):''" alt="" height="80" />
        </div>
        <div v-html="article.content"></div>
    </article>
</template>
<script setup lang="ts">
const { getItems} = useDirectusItems();
const { getThumbnail: img } = useDirectusFiles();
interface Article {
    id: string;
    title: string;
    slug: string;
    content: string;
    image:string;
}

const route = useRoute();

const article:Article = ref<Article>({})

const filter = { slug:route.params.slug as string};
try{
    const item=await getItems<Article>({
        collection:"article",
        params:{
            filter
        }
    })

    if(!item || item.length==0){
        throw createError({
            statusCode: 404,
            statusMessage: 'Post Not Found'
        });
    }
    
    article.value = item[0]??{}
    
    useHead({
        title:article.value.title??'',
    })
    

}catch(e){
    console.log(e)
    // throw createError({
    //     statusCode: 404,
    //     statusMessage: 'Post Not Found'
    // });
}


</script>

image-20230906052827499

后台管理

image-20230906052907206

模型配置

以上常用模型字段配置.

多语言显示

默认后台字段是英文展示的,需要配置中文翻译 .

image-20230906192136100

展示:

image-20230906192239859

关联关系

以分类为例.关联category表

image-20230906192637653

自动更新用户

author 作者,创建时自动绑定当前用户.

  1. 创建一个多对一的模型,关联到系统用户

image-20230906195530258

  1. 在高级配置上,选择"创建时","保存当前用户ID"

image-20230906195655722

当创建后,作者字段自动更新.

自定义模型展示

有时系统给我们提供的展示组件样式不能满足需求. directus提供方便的扩展接口.

其中的有自定义展示组件,使用vue3作为界面语言.

我们实现一个字符串ellipsis,展示更多组件...

创建脚手架:

npx create-directus-extension@latest

选择display,取名为ellipsis

image-20230907045605189

index.ts

import { defineDisplay } from '@directus/extensions-sdk';
import DisplayComponent from './display.vue';

export default defineDisplay({
    // 组件名
    id: 'ellipsis',
    name: '自动缩略',
    icon: 'more',
    description: '文字超过显示缩略...',
    component: DisplayComponent,
    // 配置
    options:[
        {
            field: 'ellipsis',
            type: 'boolean',
            name: '自动缩略',
            schema: {
                default_value: true,
            },
            meta: {
                interface: 'boolean',
                options: {
                    label: 'Yes',
                },
                width: 'half',
            },
        }
    ],
    // 支持字段类型
    types: ['string'],
});

display.vue

<template>
    <div class="v-ellipsis">
        <span :aria-label="value" :title="value" class="value">{{ value }}</span>
    </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
    props: {
        value: {
            type: String,
            default: null,
        },
        ellipsis: {
            type: Boolean,
            default: true,
        }
    },
});
</script>
<style scoped>
    .v-ellipsis{
        width: 100%;
        margin: 0px;
        padding: 0px;
        overflow: hidden;
        position: static;
    }

    .v-ellipsis > .value {
        display: inline-block;
        white-space: nowrap;
        text-overflow: ellipsis;
        overflow: hidden;
        width:100%;
        word-break: keep-all;    
    }

    .value::after {
        content: attr(title);
        background-color: #3339;
        color: #fff;
        padding: 5px;
        border-radius: 5px;
        position: absolute;
        z-index: 99;
        margin: 1em 0;
        display: none;
        pointer-events: none;
        word-break: break-all;
        white-space: break-spaces;
        max-width: 40vw;
        font-size: 14px;
    }

    .value:hover::after {
        display: block;
    }

</style>

打包到后,把dist 目录复制到扩展displays/xxx目录下.

因为用到vue,ts之类的,需要预编译. 如果单纯es5,es6 直接写在扩展目录也可以.

image-20230907070005403

项目如果设置 EXTENSIONS_AUTO_RELOAD: true,会自动加载扩展.

使用显示 扩展

在模型字段设置里,选择

image-20230907070348172

查看效果:

image-20230907070427541

扩展Api

directus 适合一些逻辑简单业务.一些复杂的功能需要编程来完成.

自定义API

扩展Api,提供给客户端使用.

参考: https://docs.directus.io/extensions/endpoints.html

extensions/endpoints/ 扩展目录下创建一个接口.

如果extensions/endpoints/hello/index.js 注意接口名是文件名,避免冲突,不要取系统内的api名.

export default (router,context) => {
    router.get('/', (req, res) => 
        res.send('Hello, World!')
    );
};

image-20230907183802059

扩展加载完后. 就可以通过 host+hello 来访问了.

http://127.0.0.1:8055/hello

image-20230907183941969

参数:

完整示例:

export default (router,context) => {
    router.get('/*', (req, res,next) => {
        res.set('Content-Type', 'text/json');
        res.status(200);
        res.send({
            "data":"hello world!",
            "code":200,
            "msg":"success",
            "req":req.query,
        })
    });
};

请求转发

统一管理第三方接口. 可以使用代理来访问第三方API.

export default {
    id:"gate",
    handler:(router,context) => {
        router.all('/*', async(req, res,next) => {
            const apiHost="http://192.168.123.13:9501"
            const path=req.path?.trim("/")
            const url =`${apiHost}${path}`
            try {
                const response = await fetch(url);
                if (response.ok) {
                    res.send(await response.text());
                } else {
                    res.status(response.status);
                    res.send(response.statusText);
                }
            } catch (error) {
                res.status(500);
                res.send(error.message);
            }
        })
    }
};

加载到扩展中.启动一个PHP http://192.168.123.13:9501

请求 /gate/相当于 http://192.168.123.13:9501

image-20230907205434336

但是每次参数写一转发次太麻烦.

反向代理服务

上面只是简单请求转发,完整的代理可以使用http-proxy-middleware来实现

因为使用其他包. 我们使用create-directus-extension 脚手架来创建.

create-directus-extension

创建一个directus-endpoint-proxy项目

image-20230907211813049

安装yarn add --dev http-proxy-middleware

import {createProxyMiddleware} from 'http-proxy-middleware'
const ApiHost="http://192.168.123.13:9501"
export default {
    id: 'proxy',
    handler: (router) => {
        router.all('/*',createProxyMiddleware({
             target: ApiHost ,
             changeOrigin: true,
             pathRewrite: {
                '^/proxy':'/'
             }
        }));
    },
};

代码结构:

image-20230907225220303

打包

yarn build

由于扩展是监听根目录的index.js/index.ts/packages.json ,生效需要修改一下package.json的版本号.或者把打包后的dist文件复制到endpoints/proxy/目录下.

代理已经转发到 /proxy ==> http://192.168.123.23:9501/

image-20230907225725801

单页面

创建一些复杂的单页面展示,如产品说明页,联系我们,下载页等等.

可以重复使用.

我们分析一个页面组成,常用由几块功能组成.

标题+富文本内容,标题+简介+图片,标题+简介+多组卡片

参考https://docs.directus.io/guides/headless-cms/reusable-components.html

创建多个页面块类型.

  • 超级模块: block_hero,标题,简介,图片和按扭. 一般用于首屏介绍 .
  • 富文本模块: block_richtext,包含标题和自定义富文本内容. 大块内容
  • 卡片组模块: block_cardgroup,包含标题,简介,卡片组. 成员介绍, 产品列表, 公司列表....

超级模块:

- id (uuid)
- headline (Type: String, Interface: Input)
- content (Type: Text, Interface: WYSIWYG)
- buttons (Type: JSON, Interface: Repeater)
  - label (Type: String, Interface: Input)
  - href (Type: String, Interface: Input)
  - variant (Type: String, Interface: Input)
- image (Type: uuid / single file, Interface: Image

image-20230908232957675

富文本模块:

- id (uuid)
- headline (Type: String, Interface: Input)
- content (Type: Text, Interface: WYSIWYG)

image-20230909001106366

block_cardgroup_cards表:

- id (uuid)
- headline (Type: String, Interface: Input)
- content (Type: Text, Interface: WYSIWYG)
- image (Type: uuid / single file, Interface: Image
- ccid (Type: M2O, Related Collection:block_cardgroup_cards) 

image-20230915184926196

卡片组模块:

- id (uuid)
- headline (Type: String, Interface: Input)
- content (Type: Text, Interface: WYSIWYG)
- group_type (Type: String, Interface: Radio, Options: ['articles', 'custom'] )
- articles (Type: M2M, Conditions: 详情隐藏 IF group_type != 'articles', Related Collection: posts)
- cards (Type: O2M, Conditions: 详情隐藏 IF group_type === 'custom', Related Collection: block_cardgroup_cards)
  • articles: 多对多,关联articles表
  • cards: 一对多,关联block_cardgroup_cards

image-20230909001151209

页面模型,包这几模块组合起来.

页面模型

image-20230915185927097

image-20230915185523811

image-20230915185849545

image-20230915185812222

最后组成一个多模块的单独页面

image-20230915190144481

原作者:阿金
本文地址:https://hi-arkin.com/archives/directus-deadless.html

标签: directus deadless

(本篇完)

评论