22 KiB
name, description
| name | description |
|---|---|
| ulthon-card-template | 自定义卡片/模板形态动手配方:面向用自己的列表页定制专属卡片形态的下游开发者。涵盖适用判断、layoutList 五级模板协议选型(tplFunction/tplLay/tplVue/tplLayId/tplVueId)、渲染上下文契约(_cells/_operatHtml/_checked/_disabled 与上下文冻结)、以商品卡为蓝本的 step-by-step 配方、设计模式库(头部三件套/图面悬浮开关/价格层级/状态徽标/底部分隔/图片降级/响应式/统一设计语言)、取值通道三规则、坑与红线、验收 checklist。需要定制卡片/自定义卡片模板/卡片模板/商品卡/名片/多形态模板/layoutList 自定义条目/tplVueId,或排查自定义模板不渲染、点卡片误勾选、模板数据被改无效时调用。机制原理(受控数据流、五级协议引擎、双端注入、layoutDefault 三层链)见 ulthon-admin-table 技能第十一章,本技能定位是动手配方,不复述机制。 |
自定义卡片模板定制指南(ulthon-card-template)
本技能教下游开发者为自己的列表页定制卡片/模板形态。全部配方来自两个实战模板:用户管理名片(extend/base/admin/view/system/admin/index.html 的 usercardTpl)与商品列表商品卡(extend/base/admin/view/mall/goods/index.html 的 goodscardTpl),两者都是框架分发、可直接对照的官方范例。
机制原理(受控数据流为什么零请求、五级协议引擎如何回退、layoutDefault 三层解析链)不在本技能展开,统一见 ulthon-admin-table 技能第十一章。本技能回答的是"怎么动手写"。
一、适用判断:先想清楚要不要定制
不定制就够用的情况(多数页面属于这类):ua.table.render 不配 layoutList 时,框架自动注入"表格 + 内置 card"双形态,工具栏自动出现"视图"按钮,字段按 label:value 紧凑网格渲染,零配置零模板。内置 card 的信息密度对管理后台的字段列表场景通常已经足够,够用就别写模板。
值得定制的情况:
- 需要语义化排版:头部大图 + 价格层级 + 状态徽标这类商品/内容展示形态,内置 card 的 label:value 网格表达不了
- 需要品牌感:自定义配色、圆角、卡片结构(头部三件套、悬浮开关等)
- 需要字段编排控制:哪些字段大字强调、哪些弱化、哪些组合进徽标,与表格列语义不同
锁定纯表格的出口:如果页面明确不需要任何卡片形态(例如纯运维日志类页面),配单条目清单即可,工具栏不会出现视图按钮:
// 场景出处:本轮 QA 实证(table-card-global-enabled 计划 Task9 场景 2,host 页临时配置验证)
layoutList: ['table'],
一个例外提醒:显式传 controlled: false 逃生口的页面(依赖引擎 url 模式行为的深度定制页,如自定义 parseData)整条多形态链路都不可用,配了 layoutList 也不会有视图按钮。判断自己是否属于这类页面见 ulthon-admin-table 第十一章 11.1。
二、五级协议选型速查
layoutList 自定义条目提供五级模板钩子,按优先级取第一个命中的:
| 优先级 | 钩子 | 类型 | 引擎 | 适用场景 |
|---|---|---|---|---|
| 1 | tplFunction |
function | 框架行循环 | 纯 JS 拼 HTML,逻辑重、不想引模板引擎 |
| 2 | tplLay |
string | laytpl | laytpl 模板字符串,行级作用域 |
| 3 | tplVue |
string | Vue 模板字符串 | Vue 模板,列表级单实例 |
| 4 | tplLayId |
string | laytpl | <script type="text/html"> 节点选择器 |
| 5 | tplVueId |
string | Vue | <script type="text/x-template"> 节点选择器 |
条目写法(对象条目,name 必填,title 供视图下拉显示,钩子提供一种即生效):
// 出处:extend/base/admin/view/system/admin/index.js:12(官方名片示例)
layoutList: ['table', {name: 'usercard', title: '名片', tplVueId: '#usercardTpl'}, 'card'],
选型建议:官方两个范例(用户名片、商品卡)用的都是 tplVueId,推荐跟随。理由:
- 模板以 HTML 节点形式写在 index.html 里,与页面级
<style>同处一文件,结构清晰 - Vue 模板的
{{ }}原生插值自动 HTML 转义,纯文本字段不用操心转义(laytpl 需要区分{{ }}转义与{{- }}原样输出,容易踩坑) @error、v-if、:class等指令让图片降级、条件渲染这类需求写起来最短
五种钩子全无且条目名不是 table 时,框架 console.warn 并回退表格形态;模板渲染失败(节点缺失、laytpl 错误、Vue 挂载失败)同样自动回退且不写形态记忆。这些兜底行为详见 ulthon-admin-table 第十一章 11.2。
三、渲染上下文契约(自定义模板拿到什么、不能做什么)
行上下文(Vue 形态的 v-for="d in rows" 里的 d):
| 字段 | 说明 |
|---|---|
...row |
行原始字段全部可用(username、title、price、create_time 等) |
_cells |
对象,field 到该列 templet 渲染产物 HTML 的映射。只有配了渲染器的列才有内容;纯文本列恒为空串,勿引用,直接用原始字段 |
_operatHtml |
操作列渲染产物(编辑/删除等按钮 HTML,已按 data-auth-* 裁剪权限) |
_checked |
该行是否选中(store 选中集合) |
_disabled |
该行是否禁选 |
列表上下文(Vue 形态叫 list):{tableId, rows, page, count, checkedAll}。注意 laytpl 形态挂在 d._list 上,Vue 实例不代理 _ 前缀键,所以 Vue 形态叫 list,裸写 _list 会 ReferenceError。
上下文冻结:渲染上下文被冻结,处理函数(@click、@error 等)只读,修改模板数据无效且违约。需要改 DOM 时只操作元素自身样式,不改数据,数据更新一律由框架整容器重建。商品卡图片降级就是标准写法(只藏 img,不碰数据):
<!-- 出处:extend/base/admin/view/mall/goods/index.html:46-47 -->
<img v-if="d.logo" :src="d.logo" :alt="d.title" class="goodscard-img"
@error="$event.target.style.display = 'none'">
模板节点位置(硬约束):<script type="text/x-template"> 必须写在页面根容器(.layuimini-container)之外、Vue 渲染容器之外。节点写进渲染容器内会被模板引擎编译时吞掉。Vue 渲染容器由框架动态创建,元素 id 为 ua-layout-{tableRenderId}(默认页面即 #ua-layout-currentTableRenderId,取的是 render 实例 id,不是表格元素 id,按表格元素 id 写选择器会扑空)。页面结构上,写在根容器 </div> 之后即可。
行选中委托:行根元素携带 data-table-id + data-id 两属性即获得统一行选中委托,点卡片任意空白处切换勾选,零 JS。选中态经 :class 高亮回显:
<!-- 出处:extend/base/admin/view/mall/goods/index.html:35-40(usercardTpl 同构) -->
<div class="ua-data-card goodscard"
v-for="d in rows"
:key="d.id"
:data-id="d.id"
:data-table-id="list.tableId"
:class="{'ua-data-card-checked': d._checked, 'ua-data-card-disabled': d._disabled}">
委托的豁免名单(点击这些元素不会触发勾选):a, button, input, select, textarea, label, .layui-form-switch, .layui-form-checkbox, .layui-form-radio。也就是说按钮点击、switch 切换、checkbox 勾选这些交互天然不误触卡片选中,模板里不需要写任何阻止冒泡的代码。注意委托只在渲染容器内部生效(实现按 closest('#ua-layout-' + tableId) 判定),这也是模板节点必须在容器外的另一个原因。
事件无需自行绑定:操作按钮(_operatHtml 里的 data-open/data-request 属性)与 switch(_cells.status 里的 layui 结构)经页面级委托与 form 事件天然生效,switch 失败还会自动回滚重渲。
四、实战配方:以商品卡为蓝本走一遍
目标效果:商品列表增加"商品"形态,图区大图 + 悬浮状态开关 + 价格层级 + 双色库存徽标 + 底部时间/操作排布。四步完成。
第 1 步:index.html 写模板节点与页面级样式
在根容器 </div> 之后追加 <script type="text/x-template"> 节点。骨架部分如下(完整文件见 extend/base/admin/view/mall/goods/index.html,节点处的大段规约注释值得通读一遍再动手):
<!-- 出处:extend/base/admin/view/mall/goods/index.html:33-50(节选,图区部分) -->
<script type="text/x-template" id="goodscardTpl">
<div class="goodscard-list">
<div class="ua-data-card goodscard"
v-for="d in rows"
:key="d.id"
:data-id="d.id"
:data-table-id="list.tableId"
:class="{'ua-data-card-checked': d._checked, 'ua-data-card-disabled': d._disabled}">
<!-- 图区:4:3 大图 + 占位降级 + 悬浮开关 + 勾位图标 -->
<div class="goodscard-cover">
<span class="goodscard-img-empty" title="未设置图片"><i class="layui-icon layui-icon-picture"></i></span>
<img v-if="d.logo" :src="d.logo" :alt="d.title" class="goodscard-img"
@error="$event.target.style.display = 'none'">
<div class="goodscard-status" v-html="d._cells.status"></div>
<i v-if="d._checked" class="layui-icon layui-icon-ok-circle goodscard-check"></i>
</div>
<!-- 主体与底部区块完整结构见源文件 54-75 行 -->
</div>
</div>
</script>
同文件追加页面级 <style>。网格与卡片基础观感部分:
/* 出处:extend/base/admin/view/mall/goods/index.html:85-104(节选) */
.goodscard-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 12px;
}
.goodscard {
padding: 0;
overflow: hidden;
border: 1px solid #eee;
border-radius: 8px;
}
.goodscard:hover {
border-color: #eee;
box-shadow: 0 2px 12px rgba(0, 0, 0, .08);
}
.goodscard.ua-data-card-checked {
border-color: #1e9fff;
box-shadow: inset 0 0 0 1px #1e9fff;
background-color: rgba(30, 159, 255, 0.05);
}
样式边界约定:卡片基础观感(圆角 8px、#eee 边框、hover 投影、选中主题蓝描边浅底)按统一设计语言在本页覆盖,公共 .ua-data-card 族样式(public.css)不动;操作按钮排布直接复用 .ua-data-card-footer / .ua-data-card-operat / .ua-data-card-time 这些公共类,本页只调外间距。
第 2 步:index.js 配 layoutList 条目
render 配置块里加一个对象条目,name 是形态名(layoutDefault、记忆、下拉定位都用它),title 是视图下拉显示文字,tplVueId 指向第 1 步的节点 id:
// 出处:extend/base/admin/view/mall/goods/index.js:13
layoutList: ['table', {name: 'goodscard', title: '商品', tplVueId: '#goodscardTpl'}, 'card'],
条目数大于 1,工具栏自动出现"视图"按钮;保留 'card' 兜底条目可让用户再切回内置卡片,不需要就只留两项。
第 3 步(可选):layoutDefault 设默认形态
两种写法都有官方示范,按需选一:
// 对象式按端取值。出处:extend/base/admin/view/system/admin/index.js:14
layoutDefault: {pc: 'table', mobile: 'card'},
// 字符串旧写法,双端同值。出处:extend/base/admin/view/mall/goods/index.js:15
layoutDefault: 'goodscard',
不配则走端缺省:PC 默认表格,手机端缺省取清单里的 card 条目(清单无 card 时取 table,再无则首条目)。解析优先级为记忆 > 按端显式 > 端缺省,完整规则见 ulthon-admin-table 第十一章 11.2 三层链。
第 4 步:浏览器验证
切换到新形态,按第八章 checklist 过一遍。重点确认:三形态切换零网络请求、模板内开关可切且不误触勾选、图片 404 时占位显示布局不塌、窄屏单列不崩。
五、设计模式库(从两个 v2 模板提炼)
以下模式均来自 usercardTpl 与 goodscardTpl 实码,出处文件已注明,可直接搬。
| 模式 | 样式要点 | 出处 |
|---|---|---|
| 头部三件套(头像/标题/开关同行) | flex + align-items: center + gap: 10px;头像 flex: none 固定 48px;姓名/账户上下两行容器 flex: 1; min-width: 0 保省略号生效;开关容器 flex: none; margin-left: auto 悬浮右侧,不再独占一行 |
system/admin/index.html .usercard-header 族 |
| 图面悬浮开关 | 容器 position: absolute; top: 8px; right: 8px + 半透明白底(rgba(255,255,255,0.85))+ 圆角 8px + 轻投影;内层 .layui-form-switch { margin: 0 } 去掉 layui 默认外边距;图区自身 position: relative |
mall/goods/index.html .goodscard-status |
| 价格层级 | 现价强调色大字(#ff5722,18px,600 字重),货币符用 <em> 缩小到 14px;市场价灰删除线弱化(#999,12px,text-decoration: line-through);两元素 align-items: baseline 对齐 |
mall/goods/index.html .goodscard-price-row 族 |
| 状态徽标 pill(条件色) | padding: 2px 8px; border-radius: 10px; font-size: 12px;条件分支换色:v-if="d.stock < 20" 红"缺货"(文字 #ff5722 + 底 rgba(255,87,34,0.08)),v-else 绿"库存 N"(rgb(22,183,119) + 8% 透明底);阈值与表格列 valueParser 语义对齐 |
mall/goods/index.html .goodscard-stock 族 + index.js:40-45 |
| 底部分隔排布(时间左/操作右) | 容器 flex:时间 flex: 1; min-width: 0 + 省略号,操作组 flex: none;分隔线用 border-top: 1px solid #eee; padding-top: 10px; margin-top: 10px |
system/admin/index.html .usercard-footer |
| 图片 cover + @error 降级 | 占位图标层 position: absolute 常驻铺满图区,img position: relative(盖住占位,加载中先露占位后覆盖,渐进显示);object-fit: cover 撑满裁切不变形;图区用 aspect-ratio: 4 / 3 撑住高度,404 隐藏 img 后布局不塌 |
mall/goods/index.html .goodscard-cover/.goodscard-img 族 |
| 头像圆形降级 | 占位层与 img 同位叠放(都 absolute 于 48px 方形容器),都 border-radius: 50%;img @error 隐藏后露占位图标 |
system/admin/index.html .usercard-avatar 族 |
| 响应式单列 | @media (max-width: 768px) 下网格改 grid-template-columns: 1fr,窄屏不崩 |
两个模板文件尾部各有一段 |
| 统一设计语言基线 | 圆角 8px、边框 1px #eee、hover 投影 0 2px 12px rgba(0,0,0,.08)、选中态主题色描边(box-shadow: inset 0 0 0 1px #1e9fff 或同值 border)+ 浅底(rgba(30,159,255,0.05) 或 #f2f9ff);公共 .ua-data-card 族不动,只做页面级覆盖 |
两个模板的卡片根类样式 |
设计语言基线的意义:用户在"名片/商品/内置卡片"之间切换时观感一致,选中态、hover 反馈不跳变。新模板建议沿用这套基线再叠加业务样式。
六、取值通道三规则
一个字段到底用 _cells 还是原始字段,按呈现需求分三条路:
规则 1:渲染器语义字段用 _cells + v-html。 switch、price、image、list 这类列在表格里配了 templet 渲染器,卡片里想要同样的框架语义(switch 可切换、价格带货币符号、状态映射文字)就取 _cells.field 并用 v-html 输出。_cells 是渲染产物 HTML,纯文本插值会显示转义后的标签源码:
<!-- 出处:extend/base/admin/view/system/admin/index.html:47 -->
<div class="usercard-status" v-html="d._cells.status"></div>
规则 2:纯文本直接插值原始字段。 没配渲染器的字段(title、phone、create_time、销量、价格数字等)_cells 里恒为空串,直接 {{ d.xxx }} 插值,Vue 自动转义。关联对象注意判空,分类是对象不是字符串:
<!-- 出处:extend/base/admin/view/mall/goods/index.html:55-62(节选) -->
<div class="goodscard-title" :title="d.title">{{ d.title }}</div>
<span class="goodscard-cate">{{ d.cate && d.cate.title ? d.cate.title : '未分类' }}</span>
规则 3:特殊呈现绕开渲染器直绑原始字段。 名片要圆形大头像、商品卡要 4:3 大图,渲染器的内联缩略图样式不合适,:src 直绑原始字段,配第 3 章/第 5 章的降级方案:
<!-- 出处:extend/base/admin/view/system/admin/index.html:39-41 -->
<img v-if="d.head_img" :src="d.head_img" :alt="d.nickname || d.username"
class="usercard-avatar usercard-avatar-img"
@error="$event.target.style.display = 'none'">
数据类型细节(商品卡实测,来自接口实返回结构):价格字段是字符串("2.00"),市场价 0 不渲染删除线用 parseFloat(d.market_price) > 0 判断(parseFloat 在 Vue 2 全局白名单内,模板里可直接用);时间字段 create_time 由模型获取器预先格式化,直接插值即可。
七、坑与红线(全部来自实战台账)
以下每条都是本轮交付中实际踩过或实证过的行为,不是推测。
缓存键与验证环境
?t=静态缓存键未 bump 时,改完模板验证必须换全新浏览器 profile 或硬刷新,否则加载的是旧 JS/HTML。本轮 Task1 验证两次都靠新 profile 空缓存保障。- 桌面与手机验证若共用同一浏览器实例,localStorage 按 origin 共享,桌面切换留下的形态记忆会以最高优先级压过手机端缺省形态。跨端断言"默认形态"前必须
localStorage.clear()后刷新。 - 形态记忆键是
{PAGE_KEY_NAME}_table-layout-default,按页面前缀隔离(如mall.Goods_index_table-layout-default),互不干扰;但跨页断言时注意子串误伤(Watch_index会匹配到RunWatch_index)。
切换 API 与记忆的差异
ua.table.setLayoutForm(id, name)只切当前形态,不写记忆;只有用户点视图下拉条目才写setPageSetting。用 API 切换后刷新页面会回到记忆形态,这是设计行为不是 bug,自动化验收时别混用两种切换路径。
layui 2.10 专项
- switch 开启态断言查
input[type=checkbox].checked,不要查.layui-form-onswitch类:新版 layui 视觉由:checked伪类 CSS 驱动,DOM 上没有 onswitch 类,查类会得出与视觉相反的结论。
图片与布局
- 图片 404 降级必须保布局不塌:占位层常驻 +
@error只藏 img + 图区aspect-ratio(或固定高)撑住。二选一v-if写法在加载失败瞬间容器高度塌陷。 - 加载中会先露占位再被 img 覆盖,属渐进显示,可接受;img 不加
position: relative会一直被 absolute 占位层盖住。
Vue 契约
- 项目内置 Vue 2.6.10(
public/static/plugs/vue-2.6.10/),运行时编译模式:模板里@click可用,但处理函数内不得修改模板数据(上下文冻结,改了无效且违约)。DOM 副作用只操作元素自身(如@error里藏 img)。 - 列表上下文在 Vue 形态叫
list不叫_list,裸写_list会 ReferenceError(Vue 实例不代理_前缀键)。
框架行为边界
- 操作按钮配置对象自 commit
53ee2e3起有拷贝保护(渲染函数对配置只读):自定义按钮的 class/method 不会被渲染过程污染,形态反复切换后按钮样式与 URL 拼接保持稳定。修复前的版本存在"切换形态后按钮 class 丢失、URL 滚雪球"的缺陷,老版本升级后若仍见此症状先确认包含 53ee2e3。 - 形态切换零请求,但 switch 开关切换不是:点击开关会触发 modify 提交加一次列表重查(各 1 个 XHR),这是框架标准链路,验收时勿与"切换零请求"混淆。
- 卡片字段清单是 render 注册时的 cols 快照,搜索列面板运行时隐藏的列不会从卡片消失(两套状态解耦),属已知边界,修复方向已在 pro 仓库立议题跟踪,模板侧无需规避。
八、验收 checklist
模板写完,逐项过:
- 三形态循环零网络请求:表格、自定义模板、内置 card 来回切,XHR 计数不变(数据取自 store 当页数据)
- F5 记忆保持:通过视图下拉切到自定义形态后刷新,形态保持(说明下拉写入的记忆被正确读取)
- 手机单列不崩:390 宽视口下网格单列、无横向滚动(
ua.checkMobile()判定需 UA 或 screen 尺寸配合,仅改 viewport 宽度不触发) - 开关可切且不触发勾选:点卡片内 switch,POST modify 200,该卡片不出现选中态(无
ua-data-card-checked类、无勾位图标),豁免名单生效 - console 零新增错误:数据固有 404(如测试图片)可豁免,模板相关零报错;若模板渲染失败框架会 console.error 并回退表格,回退本身也是排查信号
- 交互豁免有效:点操作按钮(编辑/删除等)打开预期行为且不勾选卡片;点卡片空白处正常切换勾选
- (如有图片字段)404 降级:无图与加载失败都显示占位,卡片高度不塌
验证工具建议:命令行接口冒烟用 ulthon-tools-http-call;页面断言(形态、XHR、豁免)用浏览器实测,具体断言选择器与仿真要点见 ulthon-page-qa。
相关技能
- ulthon-admin-table:表格机制与多形态渲染机制原理(第十一章是本技能的契约来源)
- ulthon-scheme-curd-workflow:本技能定制的列表页骨架从 CURD 生成开始
- ulthon-page-qa:页面验证 checklist 与浏览器自测方法