PlatoAdmin

后台前端约定

后台界面这一侧的基准:用什么、放在哪、页面行为写在哪。前端资产在 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.phptemplate 段,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 需要 tablelayui.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 不会。

一共三个块:titlecontentscriptscript 是可选的,没有行为的屏就不写。

框架变量由 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.tplscript 块里每屏调一次)负责让筛选行永远只占一行:量一遍宽度,放不下的框折进「更多筛选」后面。折起来的框留在文档里、值还在,因为页面组查询时读的是整条 bar;反过来,如果某个要被折起来的框里已经有值(ctl_admin_oplog 会预选级别),它就不折,直接展开——否则列表显示成那样却看不出为什么。

这一步排在 requestAnimationFrame 里,因为 bar 要等页面自己的 layui.form.render() 跑完才量得准:那一步会把每个 <select> 换成宽度不同的样式化 div。shell 的块先于页面的块执行,下一帧则在两者之后。

量而不是用媒体查询:能放下几个框取决于界面语言和侧边栏收没收起来,这两样都不是断点。

5. 前后端契约

5.1 信封

所有接口返回 {code, msg, data, timestamp}admin.request() 负责拆:

  • code === 0 → resolve data,调用方拿到的就是它想要的东西
  • 会话段(-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 已经在依赖里