后台前端约定
后台界面这一侧的基准:用什么、放在哪、页面行为写在哪。前端资产在 admin/public/static,Smarty 模板在 admin/app/template。
1. 技术选型
layui 2.9.7,用仓库里已有的那一份,没有联网下载。它是 layui.all.js——26 个模块全部内联的单文件版本(grep -c layui.define 得 26,且没有 lay/modules 字样),所以不需要模块目录,完全离线可用。
没有构建步骤,将来也不打算有。 没有 npm、没有打包、没有 TypeScript。理由:这是一个后台,页面数量有限,交互复杂度有限,而构建链是要长期维护的东西——它会带来 node 版本、锁文件、CI 产物一整套问题,换来的只是几个 import 语句。
2. 目录
admin/public/static/
├── layui/ vendored 发行版,只读,永不修改
│ ├── css/layui.css
│ ├── font/iconfont.* layui.css 用 ../font/ 引用,所以必须是这个相对位置
│ └── layui.all.js
├── css/admin.css 我们的样式,叠在 layui 之上
└── js/admin.js 共享行为:请求、信封、表格默认值
js/ 下只剩 admin.js 一个文件。单屏的行为不在这里,在那一屏模板的 script 块里——见下一节。
不改 layui 的任何文件。 要改样式就在 admin.css 里叠——改 vendored 文件意味着每次升级都是一次视觉回归排查。admin.css 里目前只有两处是"补 layui 没做的":a 的下划线重置和 body 的 margin 重置,两处都实测确认过 layui 2.9.7 确实没有(grep text-decoration 只在组件内部出现,没有文档级重置)。
模板:
admin/app/template/
├── layout/shell.tpl 登录后的页面框架
├── layout/blank.tpl 未登录页面的框架(登录页、错误页)
└── {ct}/{ac}.tpl 一个动作一个模板
按控制器分子目录,不是扁平的 {ct}.{ac}.tpl:目录本身就是分组,模板多起来以后不用靠文件名前缀去扫。
3. Smarty 分隔符,以及 JS 写在哪
分隔符是 <{ 和 }>,模板里写 <{$app_name}>。配置在 admin/app/config/config.php 的 template 段,api 应用不渲染模板所以没有这一段。
改掉默认的 { } 只有一个理由:页面的 JavaScript 就写在页面里。JavaScript 是由大括号组成的,默认分隔符下模板里每一个 function () { 都是编译器要去读的标签,layui 的客户端模板 {{d.field}} 更是直接被拒。<{ 和 }> 不可能在 JS 里意外出现——<{ 不是任何运算符序列,}> 只能是 } 后面跟一个大于号,本项目的排版不会产生。
单屏的行为写在那一屏模板的 script 块里:
<{block name='script'}>
<script>
layui.use(['form', 'layer', 'table'], function ()
{
// ...
});
</script>
<{/block}>
几条约定:
- 用
layui.use,不用document.addEventListener('DOMContentLoaded', ...)。layui.all.js内联了 jQuery,use()会把回调塞进 jQuery 的 ready 队列,所以它本身就是一个 DOM ready 回调,同时还说明了这段代码依赖哪些模块。两种写法不能混用:jQuery 在layui.all.js被解析时就注册了自己的DOMContentLoaded监听,排在队列最前,于是 ready 回调一定先于之后注册的原生监听执行,混着写会把顺序调转。一个模块都不用的屏写layui.use([], function () {...})。 - 模块列表要如实写。除了本屏直接点到的
layui.x,还要算上admin.js的依赖:调admin.dialog/confirm/success/error/reveal需要layer,调admin.table需要table。layui.all.js把 26 个模块都预置了,所以现在写不写都能跑;写对是为了哪天换成按需加载的layui.js时这批代码不会集体失效。 - 模板给元素挂
data-*属性,脚本按属性选元素,不用 id 也不用内联onclick。 - 共享的东西(请求、信封、表格默认值、框架初始化)仍然在
static/js/admin.js里,它每屏都一样,所以留在文件里被缓存。
代价说清楚:页面脚本不再被单独缓存,也不能用 script-src 'self' 这种 CSP。换来的是 /static/js/page/*.js 不再能被未登录的人直接抓下来读路由——nginx 的 /static/ 前面没有鉴权。这不是安全边界(真正的门是 purview),只是少一处白送的信息。
4. 页面框架
layout/shell.tpl 用 Smarty 模板继承,不是 include header + include footer:
<{extends file='layout/shell.tpl'}>
<{block name='title'}><{$t.dashboard}><{/block}>
<{block name='content'}>
<div class="admin-card">...</div>
<{/block}>
两个 include 可能一个开了另一个没关,一个 extends 不会。
一共三个块:title、content、script。script 是可选的,没有行为的屏就不写。
框架变量由 admin\support\view::shared() 统一注入,控制器只管自己这一屏的数据:
| 变量 | 内容 |
|---|---|
$t |
common 语言包整包,模板里写 <{$t.sign_in}> |
$app_name $locale |
|
$identity_name |
当前登录者 |
$menu |
已按权限过滤、语言键已翻译的导航 |
$route |
ct:ac,前端据此高亮菜单 |
$csrf_cookie |
CSRF cookie 名,写进 <meta> 给 JS 读 |
$asset_v |
静态资源版本号;debug 下自动用时间戳 |
$t_json |
同一个语言包的 JSON,写进 <meta> 给 admin.t() 读 |
4.1 列表屏的工具栏
每个列表屏的工具栏都是同一个形状,两条:
<div class="admin-toolbar">
<div class="layui-form admin-filter" data-xxx-filter>
<div class="layui-inline">...输入框 / 下拉...</div>
...
<div class="layui-inline admin-filter-buttons">
<button ... data-xxx-search><{$t.search}></button>
<button ... data-xxx-reset><{$t.reset}></button>
</div>
</div>
<div class="admin-actions">
<button class="layui-btn layui-btn-sm ..." data-xxx-create><{$t.create}></button>
</div>
</div>
- 上面一条只筛选,下面一条只动数据。 添加、删除、清理、对账都在
.admin-actions里,尺寸小一号(layui-btn-sm),底色是--admin-bg。把「删除」和「搜索」摆在同一行,是有人找搜索的时候按到删除的原因。 搜索/重置永远是筛选行的最后两个,在每一屏的同一位置。- 没有可筛选的东西的屏(菜单树)就只有
.admin-actions一条。 - 页面的
script块读的还是[data-xxx-filter]里的[name],操作按钮一律document.querySelector找——两条分开之后这两种写法都不用改。
admin.toolbar()(layout/shell.tpl 的 script 块里每屏调一次)负责让筛选行永远只占一行:量一遍宽度,放不下的框折进「更多筛选」后面。折起来的框留在文档里、值还在,因为页面组查询时读的是整条 bar;反过来,如果某个要被折起来的框里已经有值(ctl_admin_oplog 会预选级别),它就不折,直接展开——否则列表显示成那样却看不出为什么。
这一步排在 requestAnimationFrame 里,因为 bar 要等页面自己的 layui.form.render() 跑完才量得准:那一步会把每个 <select> 换成宽度不同的样式化 div。shell 的块先于页面的块执行,下一帧则在两者之后。
量而不是用媒体查询:能放下几个框取决于界面语言和侧边栏收没收起来,这两样都不是断点。
5. 前后端契约
5.1 信封
所有接口返回 {code, msg, data, timestamp}。admin.request() 负责拆:
code === 0→ resolvedata,调用方拿到的就是它想要的东西- 会话段(
-1001~-1099)→ 直接跳登录页,不 reject 给调用方,因为调用方对此无能为力 - 其他 → 弹提示并 reject 整个信封;调用方传
quiet就自己处理(登录页就是这么把错误显示在表单上而不是弹一个两秒就消失的 toast)
5.2 表格
admin.table() 包了 table.render,把信封翻译成 layui 要的 {code, msg, count, data},并把分页参数统一成 page / page_size——和 common\control\base::_pager() 对齐。layui 自己的参数名是 page / limit,让服务端为同一个概念说两种方言比在这里配一次更糟。
5.3 CSRF
框架用的是双提交 cookie:服务端把令牌写进 cookie(特意不设 httponly,注释里写明了就是为了让客户端读),客户端把同一个值放进 X-CSRF-TOKEN 请求头,服务端比对两者并同时校验 Origin。admin.request() 对所有非 GET 请求自动带上,页面不用管。
cookie 名可配置,所以由页面写进 <meta name="csrf-cookie">,JS 读 meta 而不是把名字写死。
6. 待办
- layui 组件的内置文案是中文硬编码("无数据"等),英文界面下不对。等语言切换做起来时统一处理
- 图标目前用 layui 自带的 iconfont,够用;将来要加自己的图标再说
- 两步验证只做了校验,没做绑定:
otp_enabled=1的账号登录时会被要求输入动态码,但还没有生成密钥、扫码、确认的那个页面。common\support\totp::secret()和uri()是给它准备的,chillerlan/php-qrcode已经在依赖里