Directus无头CMS+Nuxt.js 实现文章管理系统
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调用和模型管理
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
登录界面: http://127.0.0.1:8055
管理界面: 设置项目名和默认语言
创建一个API调用那用户. 并创建token,以便后续访问.
api 获取基本信息
http://127.0.0.1:8055/server/info
创建 nuxt.js 项目
https://nuxt.com.cn/docs/getting-started/installation
安装nodejs环境等省略...
nuxt脚手架
npx nuxi init nuxt-web
yarn 或者 npm install
npm run dev
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>
API文档:
directus支持openapi 协议,可以直接通过postman或者apifox 直接导入项目
http://127.0.0.1:8055/server/specs/oas?access_token=ZHnsXk-V-G4n6QgW3OXJt7ofwu0qdymR
可以生成API文档
基础工作完成. 后面通过正常项目使用.
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 的模型
增加几个字段: title(string),price(string)
增加公开访问权限
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>
效果:
后台:
注册界面
创建一个 /auth/register.vue
组件.
利用useDirectusAuth
的register
接口,通过邮箱密码注册. 注册成功返回用户信息.
实际调用 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>
登录界面
由两个接口组成,登录接口和获取用户信息接口.
POST /auth/login
登录后获取得access_token
GET /user/me
通过access_token 获得登录用户信息
把登录状态 token 存放在cookie或者localstorage 里.
创建/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 |
Article 文章模型
字段 | 说明 | 类型 | 关联 |
---|---|---|---|
title | 标题 | String | |
slug | 唯一标识 | UID | |
image | 封面 | Media | |
content | 内容 | Text | |
category | 分类 | Relation | category |
seo | 搜索引擎 | ||
meta_title | seo标题 | String | |
meta_description | seo描述 | String | |
meta_keywords | seo关键字 | String |
创建页面
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>
详情页:
[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>
后台管理
模型配置
以上常用模型字段配置.
多语言显示
默认后台字段是英文展示的,需要配置中文翻译 .
展示:
关联关系
以分类为例.关联category表
自动更新用户
author 作者,创建时自动绑定当前用户.
- 创建一个多对一的模型,关联到系统用户
- 在高级配置上,选择"创建时","保存当前用户ID"
当创建后,作者字段自动更新.
自定义模型展示
有时系统给我们提供的展示组件样式不能满足需求. directus提供方便的扩展接口.
其中的有自定义展示组件,使用vue3作为界面语言.
我们实现一个字符串ellipsis,展示更多组件...
创建脚手架:
npx create-directus-extension@latest
选择display,取名为ellipsis
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 直接写在扩展目录也可以.
项目如果设置 EXTENSIONS_AUTO_RELOAD: true
,会自动加载扩展.
使用显示 扩展
在模型字段设置里,选择
查看效果:
扩展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!')
);
};
扩展加载完后. 就可以通过 host+hello 来访问了.
参数:
- router: 路由,参考express.Router()的实例,https://expressjs.com/en/guide/routing.html
context: 上下文信息,
- services,内部服务
- database,数据库Knex的实例. https://knexjs.org/
- getSchema,数据库信息
- env,环境变量
- logger,日志实例,https://github.com/pinojs/pino
- emitter,事件管理器实例,https://github.com/directus/directus/blob/main/api/src/emitter.ts
完整示例:
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
但是每次参数写一转发次太麻烦.
反向代理服务
上面只是简单请求转发,完整的代理可以使用http-proxy-middleware
来实现
因为使用其他包. 我们使用create-directus-extension
脚手架来创建.
create-directus-extension
创建一个directus-endpoint-proxy
项目
安装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':'/'
}
}));
},
};
代码结构:
打包
yarn build
由于扩展是监听根目录的index.js/index.ts/packages.json ,生效需要修改一下package.json的版本号.或者把打包后的dist文件复制到endpoints/proxy/目录下.
代理已经转发到 /proxy
==> http://192.168.123.23:9501/
单页面
创建一些复杂的单页面展示,如产品说明页,联系我们,下载页等等.
可以重复使用.
我们分析一个页面组成,常用由几块功能组成.
标题+富文本内容,标题+简介+图片,标题+简介+多组卡片
参考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
富文本模块:
- id (uuid)
- headline (Type: String, Interface: Input)
- content (Type: Text, Interface: WYSIWYG)
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)
卡片组模块:
- 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
页面模型,包这几模块组合起来.
页面模型
最后组成一个多模块的单独页面
原作者:阿金
本文地址:https://hi-arkin.com/archives/directus-deadless.html