Files
ulthon_admin/.agents/skills/ulthon-card-template/SKILL.md
augushong a9f9045d10 fix(table): 卡片勾选收敛至勾位图标并消除勾选重渲闪烁(pro#4)
- 委托收敛勾位判定+快速路径 class 回显
- 勾位光标样式
- usercard/goodscard 勾位适配
- 两个技能文档契约同步
2026-10-03 11:55:56 +00:00

303 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
name: "ulthon-card-template"
description: "自定义卡片/模板形态创作指南:面向想让列表页变好看、为自己的列表页定制专属卡片形态的下游开发者。涵盖适用判断(内置 card 兜底 vs 定制时机、锁定纯表格)、五级模板协议选型建议(官方范例为何都选 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),两者都是框架分发、可直接对照的官方范例。
**机制前提**:多形态机制(受控数据流为什么零请求、layoutList 五级协议与回退、双端自动注入、layoutDefault 三层解析链、模板渲染容器的构建与命名)统一由 [ulthon-admin-table](../ulthon-admin-table/SKILL.md) 技能第十一章定义,本技能不复述。本技能回答的是"怎么动手写"。
## 一、适用判断:先想清楚要不要定制
**不定制就够用的情况(多数页面属于这类)**:`ua.table.render` 不配 `layoutList` 时,框架自动注入"表格 + 内置 card"双形态,工具栏自动出现"视图"按钮,字段按 label:value 紧凑网格渲染,零配置零模板。内置 card 的信息密度对管理后台的字段列表场景通常已经足够,**够用就别写模板**。
**值得定制的情况**:
- 需要语义化排版:头部大图 + 价格层级 + 状态徽标这类商品/内容展示形态,内置 card 的 label:value 网格表达不了
- 需要品牌感:自定义配色、圆角、卡片结构(头部三件套、悬浮开关等)
- 需要字段编排控制:哪些字段大字强调、哪些弱化、哪些组合进徽标,与表格列语义不同
**锁定纯表格的出口**:页面明确不需要任何卡片形态时(例如纯运维日志类页面),配单条目清单即可:
```js
layoutList: ['table'],
```
单条目锁定的语义与判断逻辑见 ulthon-admin-table 第十一章 11.2。
**一个例外提醒**:若页面显式传 `controlled: false` 走逃生口(依赖引擎 url 模式行为的深度定制页,如自定义 parseData),不要配 `layoutList`。是否属于这类页面的判断见 ulthon-admin-table 第十一章 11.1。
## 二、模板选型建议
`layoutList` 自定义条目提供五种模板钩子,协议定义、优先级与条目写法(对象条目的 name/title/钩子字段语义)见 ulthon-admin-table 第十一章 11.2,此处只给选型结论。
**选型结论:跟随官方两个范例(用户名片、商品卡),选 `tplVueId`。** 理由:
- 模板以 HTML 节点形式写在 index.html 里,与页面级 `<style>` 同处一文件,结构清晰
- Vue 模板的 `{{ }}` 原生插值自动 HTML 转义,纯文本字段不用操心转义(laytpl 需要区分 `{{ }}` 转义与 `{{- }}` 原样输出,容易踩坑,详见第三章 laytpl 补充)
- `@error`、`v-if`、`:class` 等指令让图片降级、条件渲染这类需求写起来最短
模板渲染失败的兜底回退由框架提供,机制见 ulthon-admin-table 第十一章 11.2;对模板作者而言,回退本身就是排查信号(见第八章 checklist)。
## 三、渲染上下文契约(自定义模板拿到什么、不能做什么)
### 3.1 行上下文
Vue 形态的 `v-for="d in rows"` 里的 `d`:
| 字段 | 说明 |
|------|------|
| `...row` | 行原始字段全部可用(username、title、price、create_time 等) |
| `_cells` | 对象,field 到该列 templet 渲染产物 HTML 的映射。只有配了渲染器的列才有内容;纯文本列恒为空串,**勿引用,直接用原始字段**;隐藏列(col.hide)不产出对应键 |
| `_operatHtml` | 操作列渲染产物(编辑/删除等按钮 HTML,已按 `data-auth-*` 裁剪权限) |
| `_checked` | 该行是否选中(store 选中集合) |
| `_disabled` | 该行是否禁选 |
### 3.2 列表上下文
`{tableId, rows, page, count, checkedAll}`。laytpl 形态挂在 `d._list` 上,Vue 形态叫 `list`:Vue 实例不代理 `_` 前缀键,裸写 `_list` 会 ReferenceError。
### 3.3 上下文冻结
渲染上下文被冻结,处理函数(`@click`、`@error` 等)只读,修改模板数据无效且违约。需要改 DOM 时只操作元素自身样式,不改数据,数据更新一律由框架整容器重建。商品卡图片降级就是标准写法(只藏 img,不碰数据):
```html
<!-- 出处:extend/base/admin/view/mall/goods/index.html(goodscard-cover 图区) -->
<img v-if="d.logo" :src="d.logo" :alt="d.title" class="goodscard-img"
@error="$event.target.style.display = 'none'">
```
### 3.4 模板节点位置(硬约束)
`<script type="text/x-template">` 必须写在页面根容器(`.layuimini-container`)之外、Vue 渲染容器之外。节点写进渲染容器内会被模板引擎编译时吞掉。
渲染容器由框架动态创建(构建与命名机制见 ulthon-admin-table 第十一章 11.1/11.2),定位容器时勿按表格元素 id 推导选择器,按表格元素 id 写会扑空。页面结构上,模板节点写在根容器 `</div>` 之后即可。
### 3.5 行选中委托与豁免名单
点行内 `.ua-data-card-check` 勾位元素切换勾选,零 JS;行根元素仍需携带 `data-table-id` + `data-id` 两属性(委托按行根属性定位行),且勾位元素必须写在行内(委托按该类判定点击意图,行内没有勾位元素则该行无法勾选)。选中态经 `:class` 高亮回显,禁选行整元素不可勾选:
```html
<!-- 出处:extend/base/admin/view/mall/goods/index.html(卡片根元素,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}">
```
委托的豁免名单(点击这些元素不会触发勾选,实测于 admin-table.js 统一行选中委托段,`grep "统一行选中委托"` 定位):`a, button, input, select, textarea, label, .layui-form-switch, .layui-form-checkbox, .layui-form-radio`。也就是说按钮点击、switch 切换、checkbox 勾选这些交互天然不误触卡片选中,模板里不需要写任何阻止冒泡的代码。委托只在渲染容器内部生效(实现按容器 id closest 判定),这也是模板节点必须在容器外的另一个原因(见 3.4)。
**事件无需自行绑定**:操作按钮(`_operatHtml` 里的 data-open/data-request 属性)与 switch(`_cells.status` 里的 layui 结构)经页面级委托与 form 事件天然生效,switch 提交失败还会自动回滚重渲。
### 3.6 tplLay 形态补充(非 Vue 路线)
选 laytpl 系钩子(tplLay/tplLayId)时注意语法(layui 2.10.1 内置 laytpl v2.0.0 实测):`{{ d.x }}` 与 `{{= d.x }}` 都会 HTML 转义,`{{- d.x }}` 才是原样输出;`_cells`/`_operatHtml` 是渲染产物 HTML,必须用 `{{- }}`;纯文本用 `{{ }}` 转义更安全;`{{# }}` 写 JS 语句;列表上下文是 `d._list`。最小骨架:
```html
<!-- 页面模板区:渲染容器之外 -->
<script type="text/html" id="myCardTpl">
<div class="my-card">
<h3>{{- d._cells.title }}</h3>
<div>{{- d._operatHtml }}</div>
{{# if (d._checked) { }}<i class="layui-icon layui-icon-ok-circle"></i>{{# } }}
</div>
</script>
```
```js
layoutList: ['table', {name: 'mycard', title: '自定义卡', tplLayId: '#myCardTpl'}],
```
## 四、实战配方:以商品卡为蓝本走一遍
目标效果:商品列表增加"商品"形态,图区大图 + 悬浮状态开关 + 价格层级 + 双色库存徽标 + 底部时间/操作排布。四步完成。
### 第 1 步:index.html 写模板节点与页面级样式
在根容器 `</div>` 之后追加 `<script type="text/x-template">` 节点(位置硬约束见 3.4),同文件追加页面级 `<style>`。骨架要点如下,完整实码见 `extend/base/admin/view/mall/goods/index.html` 的 goodscardTpl 节点与 `.goodscard-*` 样式族(节点处的大段规约注释建议通读一遍再动手):
```html
<!-- 出处:extend/base/admin/view/mall/goods/index.html(goodscardTpl 节点,图区节选) -->
<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 class="layui-icon layui-icon-ok-circle goodscard-check ua-data-card-check"></i>
</div>
<!-- 主体与底部区块完整结构见源文件 goodscard-body 与 goodscard-footer -->
</div>
</div>
</script>
```
同文件追加页面级 `<style>`。网格与卡片基础观感部分:
```css
/* 出处:extend/base/admin/view/mall/goods/index.html(页面级 style,网格与基础观感节选) */
.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 配置块里加一个对象条目(字段语义见 ulthon-admin-table 第十一章 11.2),`tplVueId` 指向第 1 步的节点 id:
```js
// 出处:extend/base/admin/view/mall/goods/index.js(render 配置块)
layoutList: ['table', {name: 'goodscard', title: '商品', tplVueId: '#goodscardTpl'}, 'card'],
```
保留 `'card'` 兜底条目可让用户再切回内置卡片,不需要就只留两项。
### 第 3 步(可选):layoutDefault 设默认形态
两种写法都有官方示范,写法语义与记忆/按端/端缺省解析链见 ulthon-admin-table 第十一章 11.2,按需选一:
```js
// 对象式写法。出处:extend/base/admin/view/system/admin/index.js(render 配置块)
layoutDefault: {pc: 'table', mobile: 'card'},
// 字符串写法。出处:extend/base/admin/view/mall/goods/index.js(render 配置块)
layoutDefault: 'goodscard',
```
### 第 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 语义对齐(goods 页 stock 列 valueParser 同为 20) | mall/goods/index.html `.goodscard-stock` 族 + index.js stock 列 valueParser |
| 底部分隔排布(时间左/操作右) | 容器 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,纯文本插值会显示转义后的标签源码:
```html
<!-- 出处:extend/base/admin/view/system/admin/index.html(usercard-status 节点) -->
<div class="usercard-status" v-html="d._cells.status"></div>
```
**规则 2:纯文本直接插值原始字段。** 没配渲染器的字段(title、phone、create_time、销量、价格数字等)`_cells` 里恒为空串,直接 `{{ d.xxx }}` 插值,Vue 自动转义。关联对象注意判空,分类是对象不是字符串:
```html
<!-- 出处:extend/base/admin/view/mall/goods/index.html(goodscard-body 节选) -->
<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` 直绑原始字段,配第三章/第五章的降级方案:
```html
<!-- 出处:extend/base/admin/view/system/admin/index.html(usercard-avatar 头像节点) -->
<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,改了像没改。
- 桌面与手机验证若共用同一浏览器实例,localStorage 按 origin 共享,桌面切换留下的形态记忆会以最高优先级压过手机端缺省形态。跨端断言"默认形态"前必须 `localStorage.clear()` 后刷新。
- 跨页断言记忆时注意子串误伤(`Watch_index` 会匹配到 `RunWatch_index`),过滤条件要含控制器边界。记忆键格式、按页前缀隔离与优先级机制见 ulthon-admin-table 第十一章 11.2。
**切换路径与自动化验收**
- 视图下拉与 `ua.table.setLayoutForm` API 是两条切换路径,对形态记忆的行为不同(机制见 ulthon-admin-table 第十一章 11.2)。自动化验收勿混用,混用会把设计行为误判为 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(见 3.2)。
**框架行为边界**
- 操作按钮配置对象自 commit 53ee2e3 起有拷贝保护(渲染函数对配置只读):自定义按钮的 class/method 不会被渲染过程污染,形态反复切换后按钮样式与 URL 拼接保持稳定。这是已修复的历史坑:修复前的版本存在"切换形态后按钮 class 丢失、URL 滚雪球"的缺陷,老版本升级后若仍见此症状先确认包含 53ee2e3。
- 形态切换零请求,但 switch 开关切换不是:点击开关会触发 modify 提交加一次列表重查(各 1 个 XHR),这是框架标准链路,验收时勿与"切换零请求"混淆。
- 卡片字段清单实时跟随列显隐:列筛选面板隐藏/恢复列后,卡片形态自动重渲(colToggled 监听 + store 当页数据零请求重渲),隐藏列不产出 `_cells` 对应键,模板侧无需规避。
## 八、验收 checklist
模板写完,逐项过:
- [ ] **三形态循环零网络请求**:表格、自定义模板、内置 card 来回切,XHR 计数不变
- [ ] **F5 记忆保持**:通过视图下拉切到自定义形态后刷新,形态保持(说明下拉写入的记忆被正确读取)
- [ ] **手机单列不崩**:390 宽视口下网格单列、无横向滚动(端判定与仿真要点见 ulthon-admin-table 第十一章 11.3)
- [ ] **开关可切且不触发勾选**:点卡片内 switch,POST modify 200,该卡片不出现选中态(无 `ua-data-card-checked` 类、勾位图标保持灰色未选中态),豁免名单生效
- [ ] **console 零新增错误**:数据固有 404(如测试图片)可豁免,模板相关零报错;模板渲染失败时框架会自动回退表格(机制见 ulthon-admin-table 第十一章 11.2),回退本身也是排查信号
- [ ] **交互豁免有效**:点操作按钮(编辑/删除等)打开预期行为且不勾选卡片;点勾位图标正常切换勾选,点卡片正文空白处不触发勾选
- [ ] **(如有图片字段)404 降级**:无图与加载失败都显示占位,卡片高度不塌
验证工具建议:命令行接口冒烟用 [ulthon-tools-http-call](../ulthon-tools-http-call/SKILL.md);页面断言(形态、XHR、豁免)用浏览器实测,具体断言选择器与仿真要点见 [ulthon-page-qa](../ulthon-page-qa/SKILL.md)。
## 相关技能
- [ulthon-admin-table](../ulthon-admin-table/SKILL.md):表格机制与多形态渲染机制原理(第十一章是本技能的机制前提与契约来源)
- [ulthon-scheme-curd-workflow](../ulthon-scheme-curd-workflow/SKILL.md):本技能定制的列表页骨架从 CURD 生成开始
- [ulthon-page-qa](../ulthon-page-qa/SKILL.md):页面验证 checklist 与浏览器自测方法