PHP 包管理并搭建管理后台

在 PHP 目前在经过不断迭代之后完善了 包管理 机制, 可以通过正规包管理来部署对应的第三方包和项目维护.

包管理工具: PHP-Composer

这里如果是日常开发安装即可, 而 Linux 环境可能需要在服务器部署对应服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# 这边采用 debian 环境, 所以可以直接采用 apt 源部署
# 虽然直接官网配置能够安装最新版本, 但是还是习惯直接默认做源依赖好点
sudo apt install composer

# 很简单就安装上, 之后就是开始包管理, 这里先进入测试目录
cd /tmp

# 如果要初始化自己化的项目可以按照一下命令安装初始化
mkdir owner-project && cd owner-project # 创建并进入目录
composer init # 执行初始化
# 在命令行当中可以输入以下内容
# Package name (<vendor>/<name>) [meteorcat/owner-project] # 项目包名, 格式为 "厂商名/项目名", 例如 meteorcat/owner-project
# Description []: # 项目描述, 可选
# Author [n to skip]: # 作者信息, 格式为 姓名 <邮箱>, 例如 MeteorCat <[email protected]>, 可选
# Minimum Stability []: # 最低稳定版本, 可选值有 stable(稳定版,默认)、dev、alpha、beta 等
# Package Type (e.g. library, project, metapackage, composer-plugin) []: # 包类型, 可选值有 library(类库, 默认)、project(项目) 等
# License []: # 开源协议, 例如 MIT、Apache-2.0, 可选
# Would you like to define your dependencies (require) interactively [yes]? # 是否现在定义项目依赖? 输入 y 则可以搜索并添加依赖, 输入 n 可后续手动添加
# Would you like to define your dev dependencies (require-dev) interactively [yes]? # 是否定义开发环境依赖? 同样选 y 或 n
# Add PSR-4 autoload mapping? Maps namespace "Meteorcat\OwnerProject" to the entered relative path. [src/, n to skip]: # 加载 PSR-4 自动引入源代码目录, 默认即可
# Do you confirm generation [yes]? # 是否生成项目, 默认就创建
# 之后就会在当前目录初始化完成

# 之后就是一些命令技巧
# 安装开发环境依赖( --dev 仅开发时使用)
composer require --dev phpunit/phpunit

# 安装 >=1.0 且 <2.0 的版本
composer require phpunit/phpunit:^1.0

# 只更新某个包, 不改动其他依赖, 避免全局更新引发冲突
composer update phpunit/phpunit

# 如果不指定某个包就代表全部包更新
composer update

# 强制忽略 composer.lock, 拉取 composer.json 中定义的最新符合版本
composer update --ignore-platform-reqs

# 离线安装依赖, 如果本地已有依赖缓存,无需联网重新下载
# --no-dev: 不安装开发依赖
# --prefer-dist: 优先使用压缩包, 速度更快
composer install --no-dev --prefer-dist --optimize-autoloader


# 生成类映射表,提升加载速度
# 生产环境下优化自动加载映射, 减少文件查找耗时
composer dump-autoload --optimize
composer dump-autoload --optimize --classmap-authoritative # 生产环境终极优化(合并类映射+生成优化文件)

# 扫描已安装的依赖是否存在已知安全漏洞, 正式部署需要注意扫描是否存在漏洞
composer audit

# 检查当前项目的 Composer 配置和 PHP 环境是否符合要求, 用于环境检测
composer diagnose

# Composer 会缓存下载的包, 缓存异常时可清理
composer clear-cache


# 生产环境部署时, 避免执行自定义脚本和交互提示
composer install --no-interaction --no-scripts --optimize-autoloader

# 推荐采用的正式环境部署配置如下, 可以自动检查并且安装需要的依赖
composer install --no-interaction --no-scripts --no-dev --optimize-autoloader

这里就是简单使用方式, 其实没什么需要处理, 具体要根据项目需要变动.

CodeIgniter

官网地址: CodeIgniter

CodeIgniter 是当年最喜欢用的 Web 框架, 小巧简约且功能完备, 哪怕到现在如果涉及到管理后台项目都喜欢用他来构建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 安装依赖
sudo apt install php-intl php-mbstring php-json php-dom php-zip php-gd php-curl
cd /tmp

# 这里现在临时目录创建
# 构建最新项目并且声明为 pino-admin, 之后等待安装配置完成
composer create-project codeigniter4/appstarter pino-admin
# 创建之后生成一下目录:
# - app、public、tests、writable
# - vendor/codeigniter4/framework/system

# 进入项目目录并追加默认系统翻译模板
cd pino-admin
composer require codeigniter4/translations # 安装默认系统 i18n 翻译模板

# 需要注意内部的 writable 目录需要提供 777 权限
chmod -R 777 writable/

# 安装完成就可以启动项目, 内部已经默认携带命令行项目
php spark serve --port 19999 # 启动 Web 服务并指定 19999 端口
# 访问 http://localhost:19999 能够看到内容就代表服务已经启动, 能够看到默认的页面

# 推荐检查, 推荐(Recommended) 列显示了生产环境的推荐值, 它们在开发环境中可能会有所不同
# 可以按照推荐来修改对应值
php spark phpini:check

这里推荐几个配置, 可以直接大大简化开发速度:

至于涉及 .env 配置看下官方文档就行了, 基本上没什么需要说的.

Bootstrap

PHP 默认安装完成只有底层空架构, 所以一般需要采用 UI 框架, 大部分情况都会选择前后端分离, 两者互相分开仅有接口交互.

但是现实有时候就比较复杂, 出于成本和效率考量必须要短评快上线项目的时候, 前后端分离就带来大量的沟通和联调成本.

前后端全栈合并这种概念目前还没这么成熟, 基本上还在重走 PHP 老路, 前端有时候没有后端那种攻防经验(参照 CVE-2025-55182)

而开发后台部分我还是喜欢用 PHP + UI框架 来处理, 主要有时候仅仅只需要做个后台管理功能而已, 没必要大费周章去专门部署前端项目.

另外我很讨厌 NodeJS 项目的 node_modules 到处堪称 ‘拉屎’ 的行为, 每次看到这个目录都感觉头皮发麻

所以这么多年还是依旧采用 Bootstrap 作为底层 UI 框架, 截止到目前编写的版本已经是 Bootstrap v5.3.8.

这里推荐些搭建所需要的组件依赖(新版本的 Bootstrap 已经移除了 jQuery 依赖, 可以采用原生 JS 函数操作):

以上这些基本涵盖日常使用到的方方面面, 后续有需要可以扩展(还有 富文本编辑器图表 功能, 太多了不好说哪个更好用)

另外新版本的 Bootstrap 提供了 Composer 的包管理器安装支持:

1
2
3
4
5
6
7
8
9
# 安装最新版本的 Bootstrap 和图标库等相关库
composer require twbs/bootstrap
composer require twbs/bootstrap-icons
composer require wenzhixin/bootstrap-table

# 安装 5.3.8 UI 库和 1.13.1 Icon 库
composer require twbs/bootstrap:5.3.8
composer require twbs/bootstrap-icons:1.13.1
# 其他略

默认 composer require 这些安装包只是下载到当前 vendor 目录, 目前还没暴露给外部目录, 需要采用自定义钩子来将文件复制过去.

包括之前的 codeigniter4/translations 系统翻译 i18n 库也是仅仅下载到本地, 需要手动处理移动过去

需要在根目录的 composer.json 文件追加以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"scripts": {
"update-bootstrap-linux": [
"mkdir -p public/static/bootstrap/css public/static/bootstrap/js",
"cp -rf vendor/twbs/bootstrap/dist/css/* public/static/bootstrap/css/",
"cp -rf vendor/twbs/bootstrap/dist/js/* public/static/bootstrap/js/",
"mkdir -p public/static/bootstrap-icons/icons public/static/bootstrap-icons/font/fonts",
"cp -rf vendor/twbs/bootstrap-icons/icons/* public/static/bootstrap-icons/icons/",
"cp -rf vendor/twbs/bootstrap-icons/font/* public/static/bootstrap-icons/font/",
"mkdir -p public/static/bootstrap-table",
"cp -rf vendor/wenzhixin/bootstrap-table/dist/* public/static/bootstrap-table/"
],
"update-bootstrap-windows": [
"if not exist public\\static\\bootstrap\\css mkdir public\\static\\bootstrap\\css",
"if not exist public\\static\\bootstrap\\js mkdir public\\static\\bootstrap\\js",
"xcopy /E /Y vendor\\twbs\\bootstrap\\dist\\css\\* public\\static\\bootstrap\\css\\",
"xcopy /E /Y vendor\\twbs\\bootstrap\\dist\\js\\* public\\static\\bootstrap\\js\\",
"if not exist public\\static\\bootstrap-icons\\icons mkdir public\\static\\bootstrap-icons\\icons",
"if not exist public\\static\\bootstrap-icons\\font\\fonts mkdir public\\static\\bootstrap-icons\\font\\fonts",
"xcopy /E /Y vendor\\twbs\\bootstrap-icons\\icons\\* public\\static\\bootstrap-icons\\icons\\",
"xcopy /E /Y vendor\\twbs\\bootstrap-icons\\font\\* public\\static\\bootstrap-icons\\font\\",
"if not exist public\\static\\bootstrap-table mkdir public\\static\\bootstrap-table",
"xcopy /E /Y vendor\\wenzhixin\\bootstrap-table\\dist\\* public\\static\\bootstrap-table\\"
]
}
}

这里区分 Linux 和 Window 平台的安装功能, 只要执行输入对应平台的安装指令就会默认更新到目录之中:

1
2
3
4
5
# Linux/macOS 执行
composer run update-bootstrap-linux

# Windows 执行
composer run update-bootstrap-windows

其实可以追加命令钩子, 也就是 install 等相关指令一起跟随执行; 但是后来感觉没必要, 直接让开发者选择去更新就行

页面布局渲染

作为基本管理后台需要以下基础功能(这里提供用官方模板直接起步):

不过这里需要做 PHP 功能的页面切分, 把通用模块成以下的模块, 所以需要以下文件:

  • 主体渲染文件: app/Views/layout.php

layout.php 该内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="<?= $TITLE ?? ''; ?>">
<title><?= $TITLE ?? ''; ?></title>

<link href="<?= base_url('/static/bootstrap/css/bootstrap.min.css') ?>" rel="stylesheet"
crossorigin="anonymous">
<link href="<?= base_url('/static/bootstrap-icons/font/bootstrap-icons.css') ?>" rel="stylesheet"
crossorigin="anonymous">

<?= $this->renderSection('head') ?>
</head>
<body>

<?= $this->renderSection('body') ?>

<script src="<?= base_url('/static/bootstrap/js/bootstrap.bundle.min.js') ?>"></script>
<?= $this->renderSection('script') ?>
</body>
</html>

这里采用的 <?= $this->renderSection('') ?> 就是 CodeIgniter 的视图模板占位处理方式, 也就是后续其他页面会被填充进去.

内部 View 视图文件文件命令方法采用下划线命名法

之后创建 Login 渲染页面, 用来提供暴露外部登陆页面, 这里创建 app/Views/login.php 文件, 如下来处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
<!-- 引入扩展视图 -->
<?= $this->extend('layout') ?>

<!-- 找到 head 渲染块填充 -->
<?= $this->section('head') ?>
<style>
html,
body {
height: 100%;
}

body {
display: flex;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
background-color: #f5f5f5;
}

.form-login {
max-width: 330px;
padding: 15px;
}

.form-login .form-floating:focus-within {
z-index: 2;
}

.form-login input[name="username"] {
margin-bottom: -1px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}

.form-login input[name="password"] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
</style>
<?= $this->endSection() ?>

<!-- 找到 body 渲染块填充 -->
<?= $this->section('body') ?>

<main class="form-login w-100 m-auto text-center">
<form id="form-login-container">
<img class="mb-4" src="" alt="<?= $TITLE ?? '' ?>" width="96"
height="96">
<h1 class="h3 mb-3 fw-normal">登陆界面</h1>

<div class="form-floating">
<input type="text" class="form-control" id="floatingInput"
placeholder="用户名" name="username">
<label for="floatingInput">用户名</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="floatingPassword"
placeholder="密码" name="password">
<label for="floatingPassword">密码</label>
</div>

<hr>
<a class="w-100 btn btn-lg btn-primary"
id="form-login-container-submit"
href="javascript:void(0)">Login</a>
<p class="mt-5 mb-3 text-muted"><?= $TITLE ?? '' ?>&nbsp;&copy; 2014–<?= date('Y') ?></p>
</form>
</main>


<?= $this->endSection() ?>


<!-- 找到 script 渲染块填充 -->
<?= $this->section('script') ?>
<script>
window.addEventListener('DOMContentLoaded', function () {
// 初始化渲染

})
</script>
<?= $this->endSection() ?>

这里的 $this->section(区块名称)$this->endSection() 必须成对包围, 内部会填充到 $this->renderSection(区块名称).

现在就能创建对应控制器路由文件, 这里先临时定义文件为 app/Controllers/Login.php(类文件都采用大驼峰声明):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

namespace App\Controllers;

/**
* 登陆页面
*/
class Login extends BaseController
{
/**
* 渲染页面
* 注意可以开启 app/Config/Routing.php 的 $autoRoute = true;
* 这样可以不用手写访问路由表, 只要做访问公共类型请求方法, 比如
* GET 页面方法需要将类内部公共方法定义为 get{函数名}
* POST 请求需要将类内部公共方法定义为 post{函数名}
* 对应这不同 get{}/post{}/delete{}/put{}, 这种定义方法
* 而默认的控制器方法是 index, 所以需要定义成 getIndex
* @return string
*/
public function getIndex(): string
{
return view("login");
}
}

然后访问 http://localhost:8080/login 就能获取到渲染的页面内容, 如果没问题就可以考虑编写授权流程代码.

面板渲染

对于面板渲染推荐采用 iframe 这种独立分块渲染的方式, 因为有时候需要拆分网页页面来分别比较数据, 如果采用一体化就很不方便.

而且一体化渲染每次触发切换菜单会导致全局重新渲染, NodeJS 有局部渲染来处理则不会这样, 但总感觉在 Vue/React 渲染起来的速度总有卡顿延迟.

也可能是因为服务端这种一体成型渲染起来视觉感知比较弱, 而 Vue/React 这种前后端动态节点附加的时候能够感受到渲染延迟

这里可以采用上面官方 Dashboard 模板构建默认的 Layout 文件:

  • 面板渲染文件: app/Views/dashboard.php

模板内容可以处理成以下类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
<!-- 复用模块 -->
<?= $this->extend('layout') ?>

<?= $this->section('head') ?>
<!-- 设置后台主题色 -->
<meta name="theme-color" content="#712cf9">

<!-- 常规重写样式 -->
<style>
body {
font-size: .875rem;
}

.feather {
width: 16px;
height: 20px;
}

/*
* Sidebar
*/

.sidebar {
position: fixed;
top: 0;
/* rtl:raw:
right: 0;
*/
bottom: 0;
/* rtl:remove */
left: 0;
z-index: 100; /* Behind the navbar */
padding: 48px 0 0; /* Height of navbar */
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
}

@media (max-width: 768px) {
.sidebar {
top: 5rem;
}
}

.sidebar-sticky {
height: calc(100vh - 48px);
overflow-x: hidden;
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
}

.sidebar .nav-link {
font-weight: 500;
color: #333;
}

.sidebar .nav-link .feather {
margin-right: 4px;
color: #727272;
}

.sidebar .nav-link.active {
color: #2470dc;
}

.sidebar .nav-link:hover .feather,
.sidebar .nav-link.active .feather {
color: inherit;
}


/*
* Navbar
*/

.navbar-brand {
padding-top: .75rem;
padding-bottom: .75rem;
background-color: rgba(0, 0, 0, .25);
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
}

.navbar .navbar-toggler {
top: .25rem;
right: 1rem;
}

.navbar .form-control {
padding: .75rem 1rem;
}

.form-control-dark {
color: #fff;
background-color: rgba(255, 255, 255, .1);
border-color: rgba(255, 255, 255, .1);
}

.form-control-dark:focus {
border-color: transparent;
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
}

@media (min-width: 992px) {
#sidebar-menu.col-lg-2, #header-navbar .navbar-brand {
flex: 0 0 auto;
width: 13.66666667%;
}

#iframe-main.col-lg-10 {
flex: 0 0 auto;
width: 86.33333333%;
}
}
</style>

<!-- 将这里填充的内容扩展给引用对象使用 -->
<?= $this->renderSection('styles') ?>
<?= $this->endSection() ?>


<?= $this->section('body') ?>


<header id="header-navbar" class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3 fs-6 text-center"
href="<?= base_url("/dashboard") ?>"><?= $TITLE ?? 'Dashboard' ?></a>
<button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>


<div class="navbar-nav w-100 d-flex justify-content-end">

<!-- 移除了可能导致问题的外层nav-item容器 -->
<!-- 使用d-flex确保元素水平排列 -->
<div class="d-flex align-items-center justify-content-end">

<div class="px-1">
<div class="input-group input-group-sm">
<span class="input-group-text"
id="navbar-language-label">语言</span>
<select class="form-select form-select-sm border-0"
aria-label="语言"
aria-describedby="navbar-language-label"
id="navbar-language">
<option <?= isset($LANGUAGE) && $LANGUAGE === 'en' ? 'selected' : '' ?> value="en">
en - English
</option>
<option <?= isset($LANGUAGE) && $LANGUAGE === 'zh-CN' ? 'selected' : '' ?> value="zh-CN">
zh-CN - 简体中文
</option>
<option <?= isset($LANGUAGE) && $LANGUAGE === 'zh-TW' ? 'selected' : '' ?> value="zh-TW">
zh-TW - 繁体中文
</option>
<option <?= isset($LANGUAGE) && $LANGUAGE === 'ja' ? 'selected' : '' ?> value="ja">
ja - 日本語
</option>
</select>
</div>
</div>


<!-- 使用 Bootstrap 按钮组组件 -->
<div class="btn-group px-1 py-0" role="group" aria-label="User Operate">
<a id="navbar-refresh-system" href="javascript:void(0)"
class="btn btn-sm btn-success">刷新系统</a>
<a id="navbar-change-account" href="javascript:void(0)"
class="btn btn-sm btn-primary">修改信息</a>
<a id="navbar-exit-system" href="javascript:void(0)"
class="btn btn-sm btn-danger">退出系统</a>
</div>


</div>
</div>
</header>


<div class="container-fluid">
<div class="row">

<nav id="sidebar-menu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
<div class="position-sticky pt-3 sidebar-sticky">
<ul class="nav flex-column">

<?php foreach ($MENU ?? [
[
"name" => "测试父页面",
"children" => [
[
"url" => "/test",
"name" => "测试子页面"
]
]
]
] as $k => $v): ?>
<li class="nav-item">
<a class="nav-link d-flex justify-content-between align-items-center sidebar-menu-parent"
data-bs-toggle="collapse"
href="#dashboard-submenu-<?= $k ?>"
role="button"
aria-expanded="false">
<span class="d-flex align-items-center">
<?= isset($v['icon']) && $v['icon'] ? "<i class='{$v["icon"]} align-text-bottom me-2'></i>" : '' ?>
<span class="ms-1"><?= $v['name'] ?></span>
</span>
<i class="bi bi-chevron-down align-text-bottom transition-transform duration-300"></i>
</a>
<div class="collapse" id="dashboard-submenu-<?= $k ?>">
<ul class="nav flex-column ms-3 m-1">
<?php foreach ($v['children'] ?? [] as $sub): ?>
<li class="nav-item">
<a class="nav-link"
href="<?= isset($sub['url']) && $sub['url'] ? base_url($sub['url']) : 'javascript:void(0)' ?>"
target="system-iframe-container" rel="noopener noreferrer">
<?= isset($sub['icon']) && $sub['icon'] ? "<i class='{$sub["icon"]} align-text-center'></i>" : '' ?>
<?= $sub['name'] ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
</li>
<?php endforeach; ?>

</ul>
</div>
</nav>


<main id="iframe-main" class="col-md-9 ms-sm-auto col-lg-10 p-0 m-0" style="min-height: calc(100vh - 56px);">
<main class="w-100 h-100" style="overflow: hidden;">
<!-- 嵌入外部网页的 iframe -->
<iframe
name="system-iframe-container"
src="about:blank"
id="system-iframe-container"
class="w-100 h-100 border-0"
allowfullscreen
loading="lazy"
referrerpolicy="no-referrer-when-downgrade">
</iframe>
</main>
</main>
</div>
</div>


<?= $this->endSection() ?>



<?= $this->section('script') ?>

<!-- 将这里填充的内容扩展给引用对象使用 -->
<?= $this->renderSection('extras') ?>
<?= $this->endSection() ?>

最后追加个渲染控制器页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

namespace App\Controllers;

/**
* 渲染面板
*/
class Dashboard extends BaseController
{

/**
* 渲染首页
* @return string
*/
public function getIndex(): string
{
return view("dashboard");
}

}

启动之后访问 http://localhost:8080/dashboard 就可以看到具体的渲染效果:

dashboard