关于 zul-writer
zul-writer 是面向 ZK 开发的技能,用于创建、检查和预览 ZUL 页面,并生成对应的 Java 控制器。它可按请求选择流程步骤,也能从 UI 截图分析页面结构。
主要功能
- 01按需执行页面构建步骤
- 02生成并验证 ZUL 文件
- 03创建 ViewModel 或 Composer
- 04渲染页面并检查结果
- 05从截图分析界面布局
适用场景
- 01从需求或模型图创建 ZUL 页面
- 02检查现有页面的解析有效性
- 03预览页面并定位显示异常
- 04为页面生成对应控制器
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zkoss-demo/ agent-skill --skill zul-writer安装后在 Claude Code 中输入/zul-writer确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
assets/borderlayout-example.zulcontent-tabbox.zuldata-grid-selection-mvvm.zuldialog-popup-mvvm.zulexample-data-management-mvvm.zulexample-simple-form-mvvm.zulexample-simple-list-mvc.zulflexible-sizing.zulform-validation-mvvm.zulkanban-board.zulKanbanViewModel.javamaster-detail-mvvm.zulmvc-sample.zulmvvm-pattern-structure.zulMyComposer.javaMyViewModel.javatemplate.zulzul.xsdreferences/charts-guidelines.mdcontroller-guidelines.mdpreview-guidelines.mdui-to-component-mapping.mduse-case-guidelines.mdscripts/_skill_meta.pydetect-pattern.pypreview-zul.pyvalidate-zul.py详细介绍
ZUL 编写器
工作流概述
此技能通过 5 个步骤创建结构良好的 zul 页面:
- 明确需求 - 收集页面用途、模式和布局需求
- 生成 ZUL - 根据需求创建 ZUL 文件
- 验证 ZUL - 检查生成的 ZUL 是否正确
- 生成控制器类 - 创建对应的 Java 类(ViewModel 或 Composer)
- 预览并自我检查 - 将页面渲染为图像,并根据需求进行检查
其他入口:如果用户提供了 UI 图片(截图/模型图),请先执行下方的视觉分析,然后再继续这 5 个步骤。
只运行请求所需的步骤
这五个步骤是入口,而不是一条必须走完的流程。从零开始构建页面时需要全部步骤;大多数其他请求只需要一两个。有人把完成的 .zul 文件交给你,问它看起来是什么样,只需要运行步骤 5,其他什么都不用做——追问他们用的是 MVC 还是 MVVM,或顺手“改进”他们的标记,回答的都不是他们提出的问题,还会让他们多花时间处理一个原本满意的页面。
| 用户请求 | 要运行的步骤 |
|---|---|
| “帮我构建一个……页面”“把这个模型图转换成 ZUL” | 1 → 5,完整工作流 |
“预览 / 截图 / 告诉我 foo.zul 看起来是什么样” | 5 |
| “为什么这个图标显示为空白方框 / 这个标签被截断 / 这个区域不见了?” | 5,然后对该元素运行 --probe |
| “这个 ZUL 有效吗?”“为什么这个页面无法解析?” | 3 |
“ZK 10 有 <togglebutton> 吗?”“<charts> 能使用 sclass 吗?” | 都不用——通过 --describe 从架构中回答(步骤 2,指南 3) |
| “为这个页面编写 ViewModel” | 4 |
| “把这个页面的数据移到 ViewModel/Composer 中” | 步骤 2 中的提取阶段 → 4 → 5,并使用 --run-controllers |
| “在这个网格中添加一列”“缩窄侧边栏” | 对现有文件运行 2 → 3 → 如果更改可见,再运行 5 |
| “为我们已有的 ViewModel/Composer 构建页面” | 1 → 5,但无需提取任何内容——参见步骤 2 中的 由模型驱动的页面 |
**由模型驱动的页面要运行两次步骤 5,第二次不是修复轮次。**编号步骤是针对数据仍在标记中的页面所规定的顺序;步骤 5 确认布局后,步骤 2 中的提取阶段会将这些数据移入控制器,再通过一次 --run-controllers 渲染进行检查。因此流程是先运行 1 → 5,再回到 2 → 4 → 5,执行一次。这最后一轮很容易被忘记,恰恰是因为它是按编号倒着走的。
“为什么看起来不对?”是诊断请求,不是验证请求。 这两行放在一起,是因为它们很容易混淆:“为什么这个页面无法解析”和“为什么这个图标显示为空白方框”听起来相似,但步骤 3 只能回答第一个问题。<label sclass="z-icon-bell"/> 是有效的 ZUL——验证器会通过它,并报告页面没有问题;这恰恰会让真正的缺陷以“未发现问题”结案。页面渲染错误、验证却通过,正是步骤 3 无法发现的情况。标记已经写好;问题在于浏览器对它做了什么,只有渲染才能回答。转到步骤 5 并探测该元素,让测量结果先指出原因,再动标记——一上来就猜测既会多花一轮,还可能看似成功地“修复”错误的代码行。指出原因就是对“为什么”这一问题的完整回答;编辑是另一个问题,应由用户提出。
跳过的步骤仍会为运行的步骤提供信息——步骤 4 需要知道页面使用 MVC 还是 MVVM,步骤 5 需要有依据来判断渲染结果。直接从文件和用户消息中获取这些信息,而不是重新从步骤 1 开始:现有的 .zul 文件对其模式、布局和 ZK 版本的说明,比访谈得到的信息可靠得多。只询问确实无法推断的内容。
只被要求执行一个步骤时,报告结果,不要重写。 验证或渲染你没有编写的页面,可能会发现没人要求你修改的问题。说明你发现了什么,然后只进行用户要求的修改——或者等用户要求你修复其他问题。唯一的例外是阻碍步骤执行的缺陷:无法解析的页面不能渲染,因此要指出问题并提供修复方案。
视觉分析(针对图像/模型图)
提供 UI 截图或模型图时,请在开始 5 步工作流之前执行此分析:
- 视觉拆解:识别所有 UI 元素(布局、输入框、按钮、表格、导航)。
- 组件与布局策略:规划 ZK 组件映射(参阅 references/ui-to-component-mapping.md),并确定整体布局(例如
<borderlayout>、嵌套的<vlayout>)。 - 选项卡内容范围:如果有选项卡,确定内容边界。随选项卡切换的项目必须放在
<tabpanel>内。参阅 assets/content-tabbox.zul。 - 识别自定义样式:标出需要使用备用 HTML 元素或自定义 CSS 的区域。
过渡:利用这些发现来指导步骤 1:明确用户需求,并最终用于步骤 2:生成 ZUL 文件。
步骤 1:明确用户需求
提出有针对性的问题以了解需求。如果从图像开始,请根据视觉分析的结果来提出这些问题。
只询问尚未明确的事项。请求、项目和任何现有文件已经能回答其中一些问题——pom.xml 中的版本、所指向标记中可见的模式、提示词中描述的布局——重新询问这些内容,会让人觉得你并未查看过相关内容。
有时无人可问——例如脚本运行、批处理作业,或用户没有回应。这不是停止工作的理由,也不意味着可以凭空编造答案:请参阅问题之后的无人可问时。
要询问的问题
1. ZK 版本
从用户的项目中检测(检查 pom.xml、ivy.xml 或 build.gradle 中的 ZK 依赖版本)。如果未找到,请询问:
- 9 或更早版本
- 10.x
2. 页面用途
- 数据输入表单
- 数据列表/网格显示
- 包含多个区域的仪表板
- 对话框/弹出窗口
- 主从视图
- 搜索和结果页面
- 其他:[请说明]
3. MVC 或 MVVM 模式
以同等权重介绍这两种选项——不要将任何一种标记为“(推荐)”:
- MVVM:基于 ViewModel,使用
@bind/@command数据绑定——可测试,但需要更熟悉 ZK - MVC:基于 Composer,使用
apply和已连接的组件——直接明了,对初学者友好
两者的界限非常明确,因此不存在无法分类的页面:MVC 应用 Composer(apply="com.foo.MyComposer"),MVVM 设置 ViewModel 并使用绑定语法(viewModel="@id('vm') @init('com.foo.MyVM')",以及 @load/@bind/@command)。两者都是控制器,并控制其所在组件及其所有后代组件。
每个页面只写一种模式。 ZK 允许混用,有些项目也确实如此,但混合页面无法对“这个值从哪里来”给出单一答案,而这正是提取过程和步骤 5 的自我检查都依赖的问题。如果用户要求同时使用两种模式,请询问页面数据应遵循哪一种。
如果用户没有回答此问题,请参阅下文的无人可问时——这是唯一一个在采用默认方案之前,项目现有页面可以优先决定的问题。
4. 静态数据还是模型驱动
将此作为一个独立问题询问。它与 MVC/MVVM 无关——前者决定行为在哪里,后者决定数据在哪里——而且会改变工作的开展顺序:
- 静态数据:文本和行都写在 ZUL 中——
<label value="Acme Corp"/>,以及在 grid 中写明的<row>、在 listbox 中写明的<listitem>、在 tree 中写明的<treeitem>。适用于布局、样稿、演示,以及内容固定的其他场景。 - 模型驱动:由控制器提供数据——Composer 对其接线的组件调用
setModel(),或者页面通过@load/@bind和绑定的model读取 ViewModel。适用于任何由真实数据支持的场景。
组件的行只能来自其中一种方式,不能同时使用两种方式。 设置模型会静默丢弃标记中写明的行,因此,留在模型旁边的字面量行虽然存在于标记中,却不会显示任何内容。
无论选择哪种方式,步骤 4 中都会有一个控制器。模型驱动的页面还要分两轮构建——参见步骤 2 中的模型驱动页面。
5. 布局要求
- Borderlayout(north/south/east/west/center)
- 垂直布局(vlayout)
- 水平布局(hlayout)
- 基于 grid 的布局
- 标签页布局(tabbox)
- 组合布局
6. ZK Charts(仅在需要图表时)
如果 ZUL 页面需要 <charts> 组件,请在生成任何图表代码前遵循 references/charts-guidelines.md。
7. 主题和数据密度
如果页面设计用于展示高密度数据,建议用户使用另一个免费主题 iceblue_c。这是一个更紧凑的主题,具有更小的内边距、外边距和字体大小。
无人可问时
上述每个问题都有可供采用的默认答案,因此未得到答复只会导致采用明确说明的选项,而不会让整个流程停下来:
| 问题 | 未答复时 → 使用 | 原因 |
|---|---|---|
| 1. ZK 版本 | 10.x | 这不是新的决定——validate-zul.py 已默认使用 10,preview-zul.py 则默认使用 10.2.1。此处采用不同的默认值,会导致技能编写时与自身验证器不一致。 |
| 2. 页面用途 | 无默认值——从请求中读取 | 用户总会描述自己想要的页面。没有什么需要猜测,固定默认值反而会覆盖已经说明的内容。 |
| 3. MVC 还是 MVVM | MVC,除非项目成员意见一致——见下文 | 无人可以纠正时,Composer 和 @Wire 是更容易正确实现的选择。 |
| 4. 静态还是模型驱动 | 静态(字面量行) | 页面本来就是从这里开始的:模型驱动页面的第 1 轮使用字面量行。只有请求中指定了真实数据源时,才选择模型驱动。 |
| 5. 布局 | 无默认值——根据用途推导,并说明推导方式 | 布局是任何请求或样稿中最具体的部分。在此处设置固定默认值就意味着忽视已提供的信息。 |
| 6. ZK Charts | 不适用——仅在页面需要图表时询问 | — |
| 7. 主题和数据密度 | 保留项目的主题,并在报告中注明存在 iceblue_c | 切换主题会重绘应用中的每个页面。没人留意时,不应该这么做。 |
在最终报告中列出你使用的每个默认值。 默认值的价值在于可预测且事后可核查;不告知他人的默认值则两者都不是。
项目当前的做法(仅问题 3)
如果一个项目中所有现有页面都是 MVVM,这就是证据,而非猜测——在其中添加一个 MVC 页面会让代码库变得更糟,而默认设置无法发现这一点。因此,仅针对问题 3,请先检查,再考虑使用默认值:
uv run <skill-base-dir>/scripts/detect-pattern.py <project-root>
它只读取 ZUL 部分,因为两种模式都会在这里的属性中声明。Java 部分无法如实统计:ViewModel 是普通类,@Init 是可选的,因此统计它会漏掉不使用 @Init 的 ViewModel;统计 Composer 子类又会把所有从未应用到页面的基类和辅助类都算进去。
PATTERN: mixed (MVC 6, MVVM 2)
USE: mvc -- the project uses both, so it has no single answer to give
只有在结果完全一致时才遵循项目现状——PATTERN: mvc 或 PATTERN: mvvm。其他任何情况,包括 6 比 2 这种明显偏向一方的结果,都应保留 MVC 默认值,因为一个已经混用模式的项目并不能告诉你它想采用哪种模式。无论结果如何,都要在报告中说明扫描结果以及你采用的答案。
其他六个问题并非如此。模式是在整个代码库中统一采用的一项架构决策;每个页面的用途和布局各不相同,而 ZK 版本应从构建文件中获取,而非通过问卷调查确定。
步骤 2:生成 ZUL 文件
生成指南
生成 ZUL 文件时,请遵循以下技术指南:
-
映射 UI 元素:查阅 references/ui-to-component-mapping.md,选择正确的 ZK 组件。
- 优先使用 ZK 组件,而不是原生 HTML。
- 有效使用
<borderlayout>、<vlayout>和<hlayout>等布局组件。
-
通过类设置样式,而非使用
style属性:- 将页面的 CSS 放在文件顶部附近的一个
<style>元素中——不要使用<?style ?>处理指令——并通过 ZK 组件上的sclass(原生n:元素上使用class)附加样式。类名应描述元素本身的用途(sclass="stat-card"),并为页面的类添加简短的专属前缀,以免与 ZK 的z-类或其他页面的 CSS 冲突。 - 这不只是个人偏好。
style属性会渲染到组件自身的元素上,其优先级高于任何样式表规则:页面将无法应用主题,后来编写的 CSS 会悄无声息地失效,而且任何:hover、:focus或@media规则都无法作用于该元素。将声明复制到十几个组件上,也会产生十几组近乎相同的值;修改一个类,则所有实例都会同步更新。 - 如果类看起来“不起作用”,可能是主题样式的优先级更高——
.z-button的优先级高于.my-btn,因此主题样式会胜出。应限定选择器(.my-page button.my-btn { … }),而不是改用style;正是这种做法会让页面最终到处都使用内联样式。 - 尺寸和间距应由组件控制,而不是由 CSS 控制。
hflex/vflex、width、height、spacing和valign都是组件自身的 API,并能与 ZK 布局引擎协同工作;在 CSS 中重新实现这些属性会导致 flex 布局出问题。让组件负责盒模型,让类负责外观。 - 唯一合理的例外:仅在运行时才有的值。 来自数据的颜色或宽度(
style="@load('background-color:'.concat(tag.color))")无法设为静态类。仅保留这一个内联声明,其余样式放在类中。 - 如果使用原生 HTML 回退元素(例如
<n:div>),请将其所需的 CSS 一并放入同一个<style>块中。
- 将页面的 CSS 放在文件顶部附近的一个
-
编写前先查阅 Schema——它已随附、内容精确,而且无需付费:
在编写此前未用过的组件,或为此前未使用过的组件添加属性之前,请先查询:
uv run <skill-base-dir>/scripts/validate-zul.py --describe <component> [--attr <name>] --zk-version <detected-version># Does <charts> take sclass? -> no: className or zclass uv run ~/.claude/skills/zul-writer/scripts/validate-zul.py --describe charts --attr sclass # Does <togglebutton> exist in ZK 10? -> no such component uv run ~/.claude/skills/zul-writer/scripts/validate-zul.py --describe togglebutton请务必先查询,不要在两个似曾相识的拼写之间猜测。 这里常见的问题并非不知道如何构建网格,而是明明知道三种看似合理的拼写,却选错了一种。事后验证虽然能发现问题,但会多花一轮;先查询同一份 Schema,就能立即在本地得到答案。打印出的列表中没有的属性,即使看起来再合理,也不受支持。
需要注意一个限制:随附的 Schema 是一份 10.x 文档。对于 ZK 9 目标版本,其中缺少的信息只能作为参考,并不能据此定论;适用时,Schema 会明确说明这一点。
-
ZK 文档:
- 如果有可用的
zk-docMCP 服务,请查询它(工具:search_zk_docs)以获取详细的组件信息。 - 属性和事件详情请参阅 ZK Javadoc。
- 如果有可用的
-
最佳实践:
- 响应式布局优先使用
hflex/vflex,而不是固定像素宽度。hflex="min"会让组件宽度适应其内容——适用于<button>与hflex="1"的字段并排显示的情况(参见 assets/flexible-sizing.zul)。 - 使用有意义的 ID,并遵循 assets/template.zul 的结构。
- XML 注释中绝不要包含
--。 XML 禁止在和之间出现该字符,因此在 Java 代码中完全有效的分隔符,在 ZUL 中却会导致解析错误。请改用 `=`:。
- 响应式布局优先使用
模型驱动的页面:先写入数据,再取出
值来自控制器的页面,在控制器运行之前无法显示实际内容。在此之前,@load(vm.customer) 会渲染为灰暗的表达式文本,而绑定的 model 会渲染为几行占位内容——因此,列宽、换行、卡片高度以及一行统计数据能否放下,都是根据并非页面实际内容的文本来判断的。这就是页面通过第 5 步,却在首次真正运行时呈现不正确的原因。
你走的是哪条路径,只需查一下,不必提问。 不要问用户,答案就在眼前。你从零开始编写的页面,需要一个尚未编写的控制器——在 ZK 中,Composer 或 ViewModel 属于页面,而非共享服务层,因此新页面意味着新控制器,并应采用下面的两遍流程。只有现有的 .zul 才可能已经指定了类;如果是这种情况,就打开它:如果它存在且能编译,就没有什么需要提取的——从一开始就使用 --run-controllers 渲染,并根据真实数据进行判断。
什么算作数据。 会随数据变化的值就是数据。无论数据内容如何都保持不变的文字属于界面固定内容,应永久写在 ZUL 中——列标题、按钮标题、分区标题、单位、空状态消息、验证提示。单元格值、列表项、计数、金额和状态标签都属于数据。判断标准不是“它是不是文本”,而是明天面对明天的数据行时,这个词是否仍然正确。
第 1 遍——字面数据,真实形态
用字面值编写第一个版本,并让其形态与后续替换它的数据一致:长度合理的姓名、位数符合实际的价格,以及足以填满区域的行数。行以标记形式写入——网格中使用 <row>,列表框中使用 <listitem>,树中使用 <treeitem>。字面值会直接渲染出来,因此第 5 步的截图就是用户实际会看到的页面;而且这一遍更简洁,因为没有任何 @load,页面上也就不会有占位内容。
你在这遍的第 4 步中编写的控制器只负责行为,不负责数据——事件处理程序、@Command 和连接配置。现在把数据放进去,会抵消这遍流程的全部意义:第 5 步会通过 --run-controllers 渲染出真实数据行,你又会回到根据未经检查的文本来判断布局的状态。
在这里反复调整,直到布局和样式都正确。
图表没有第 1 遍。 <charts> 没有字面形式——无法像在标记中写出 <row> 那样写出一个系列——因此,两遍流程不适用于图表。从一开始就把图表数据写入控制器,并在图表区域首次渲染时使用 --run-controllers,而页面其余部分仍然采用字面值流程。代价是页面的首次渲染现在依赖控制器能否编译,因此,在对缺失的图表下结论之前,先查看 CONTROLLERS: 行:过期的 .class 看起来与从未绘制过的图表一模一样。
第 2 轮——提取,在第 5 步确定布局之后
这是工作流中唯一一个在第 5 步之后而非之前执行的操作,因此很容易被跳过。它的触发条件是“布局已确定”,这是第 5 步的结果。按顺序执行以下四项操作:
-
将每个字面值移入控制器,作为字段、getter 或列表。
-
让 ZUL 指向它。 MVVM 会将值替换为读取它的绑定(
@load(vm.customer.name)、model="@load(vm.items)")。MVC 则完全没有 ZUL 端的表达式:Composer 根据组件的id关联组件并调用setModel(...),因此 ZUL 保留id,不需要添加任何其他内容。<template name="model">是个例外,而且两种模式读取它的方式不同。 模板会为每个条目重复渲染标记,因此无论页面采用哪种模式,都需要表达式:页面 模板内部 模板变量 MVVM @load(node.data.name)binder 会遵循 var="node"MVC ${each.data.name}——普通 EL始终是 each;自定义var会被静默忽略如果把两者弄反,就会多花一轮,因为无法解析的变量会渲染为空文本,而不是报错:在 MVC 树中使用
${node.data.name},生成的树结构、缩进、展开状态和选择状态都正确,但所有标签都是空白。只有使用${each.name}探测时,才会得到实际提示——Property 'name' not found on type DefaultTreeNode。这也说明了规则的另一半:对于网格或列表框模板,each就是条目本身(${each.action});但对于树,它是TreeNode,因此数据还要再深入一层(${each.data.action})。 -
删除字面行。 它们并非无害的残留。设置模型——无论通过绑定还是
setModel(),无论模型完整还是为空——都会丢弃标记中写入的行,而且不会发出警告,因此页面渲染正常,但标记中仍留着不会显示任何内容的行。查看图像无法发现这一点,因此第 5 步的LAYOUT区块会用literal-rows-discarded为你测量这一情况。 -
不要更改其他任何内容——组件、
sclass和hflex都保持不变。提取只移动值,不改变结构,因此如果之后页面发生偏移,应检查提取过程。
这里可能会遇到两条 schema 报错,它们都是打包的 XSD 误报,并非页面有问题——Layer 2 比 ZK 更严格:
<tree>的唯一子元素是<template>时会失败:只要 tree 有任何子元素,treeType就要求有treecols或treechildren。添加一个空的<treechildren/>即可通过。模型仍会填充树,而空元素并不是字面行。<listbox>带有字面<listitem>却没有<listhead>时,也会以同样的方式失败。通常本来就需要<listhead>。
带有 <columns> 和模板的 <grid> 可以按原样通过。此类问题的其他情况请参阅 test/known-failures.txt 中的 B1 组。
然后使用 --run-controllers 再渲染一次,以确认没有发生变化。这次渲染检查的是提取,而不是布局,因此它不属于修复轮次——发现的任何问题都应使用第 2 轮自己的预算修复,而不是占用第 1 轮剩余的预算。请参阅第 5 步中的 轮次数量。
布局与组件模式
XML 与模式结构
- 基础模板:assets/template.zul
- MVC 结构:assets/mvc-sample.zul
- MVVM 结构:assets/mvvm-pattern-structure.zul
尺寸与布局
- 灵活尺寸(hflex/vflex):assets/flexible-sizing.zul
- Borderlayout 示例:assets/borderlayout-example.zul
常见 MVVM 模式
步骤 3:验证生成的 ZUL
使用此技能基础目录中的脚本运行验证(技能上下文标题中提供了“此技能的基础目录:”)。传入步骤 1 中检测到的 ZK 版本,通过 --zk-version 确保第 4 层检查与目标版本匹配:
uv run <skill-base-dir>/scripts/validate-zul.py --zk-version <detected-version> <path-to-zul-file>
示例:如果技能基础目录为 ~/.claude/skills/zul-writer,且项目目标版本为 ZK 10.3.0,请运行:
uv run ~/.claude/skills/zul-writer/scripts/validate-zul.py --zk-version 10.3.0 path/to/file.zul
- 第 1 层:XML 格式良好性(无依赖项)。多根片段会在验证前自动用
<zk>包装。 - 第 2 层:XSD 架构验证(需要
lxml) - 第 3 层:属性位置检查(需要
lxml)——捕获位置错误的属性(例如,将iconSclass用在textbox上) - 第 5 层:内联样式提示——列出应放入通过
sclass附加的<style>类中的静态style="..."属性。此检查只报告问题,不会导致运行失败,因此应将每一行都视为需要修复的问题,而非可以忽略的提示;数据驱动的style="@load(...)"会按预期跳过。 - 第 4 层:目标 ZK 版本的兼容性检查——检查所有目标版本中已移除/已弃用的 API,并根据
--zk-version检查仅限 ZK 10 的 API(例如,已移除的<fragment>,或新增的accept/responsive属性)。若省略,则默认为10。 - 第 6 层:运行时语义——检查在所有静态检查中都合法、但页面构建时仍会抛出异常的标记。目前这种情况只涉及字面量
selectedIndex:<combobox selectedIndex="0"/>能通过第 1~5 层,却会因Out of bound: 0 while size=0而失败。该索引会在组件自身的子组件附加之前、任何控制器运行之前,以及绑定器设置模型之前应用——因此,在combobox、listbox、radiogroup和tabbox上,不论标记中写了什么,都会抛出异常。写出各项并无帮助,设置model="..."也一样:由模型驱动的<listbox model="@load(vm.items)" selectedIndex="0">会抛出相同异常。应改为指定选择项——在只读 combobox 上使用value="...",在<listitem>/<radio>/<tab>上使用selected="true",或在模型就绪后调用控制器。 **selectedIndex="-1"并非看起来那样是通用的规避方法。**在 ZK 9.6.6 和 ZK 10.3.0.1 上测试结果一致:<tabbox selectedIndex="-1">仍会因No tab at all而失败,因为 setter 需要一个尚不存在的<tabs>子项——对 tabbox 来说,没有任何字面量值是安全的。<cardlayout selectedIndex="-1">会因Out of bound: -1 while size=2而失败:它的卡片会先附加,因此会对值进行边界检查,且没有“未选择”状态;位于0..cards-1范围内的卡片索引则没有问题。selectbox在有无模型的情况下都能接受任意字面量值,因此此层完全不会检查它。 - 第 7 层:控制器交叉检查——仅在传入
--controller时运行(见步骤 4)。
前置条件
第 2 层和第 3 层需要 lxml。uv run 会自动处理:脚本通过 PEP 723 内联元数据在临时环境中配置 lxml,无需手动设置。如果无法使用 uv,则改用普通解释器运行,脚本会作为后备方案自行安装 lxml:
python3 <skill-base-dir>/scripts/validate-zul.py --zk-version <detected-version> <path-to-zul-file>
(在 Windows 上,使用 python 而不是 python3。)
使用情况跟踪
运行此脚本还会在后台线程中发送匿名的汇总使用情况 ping(仅包含技能名称和版本,不含标识符),不会延迟或阻塞验证。可通过 DO_NOT_TRACK=1 或 TRACK_URL="" 选择退出;也可在单次运行时使用 --dev 选择退出——开发或测试此技能时运行脚本应传入该参数,以免被计为使用情况。
仅要求验证
报告未通过的层级,引用脚本列出的行,并说明每一项的含义——然后停止。验证请求是要求告知问题所在;只有当用户要求修复,或修复本来就是当前任务时,才编辑文件。
验证后检查清单
模式一致性
- MVC:使用
apply属性,不使用 MVVM 绑定表达式 - MVVM:使用
viewModel属性,并采用正确的绑定语法 - 整个页面统一使用一种模式,不要每个组件各用一种
- 组件不能同时具有
model和字面量行 — 参见步骤 2 中的 由模型驱动的页面
最佳实践
- ID 在各自的 ID 空间所属范围(
<window>、<idspace>)内唯一 - 外观应通过附有
sclass的<style>类设置;唯一保留的style属性,其值应来自数据。第 5 层会列出它发现的其他属性 - 优先使用
hflex/vflex,而非固定尺寸 - 为辅助功能提供有意义的标签和工具提示
步骤 4:生成控制器类
为 ZUL 页面生成对应的 Java 控制器类(ViewModel 或 Composer)。
静态数据页面也要生成。 值可能是固定的,但页面仍然需要执行某些操作,而开发者需要的是这类绑定的结构:Composer 如何连接组件并监听其事件,ViewModel 如何声明 @Command,以及 ZUL 如何编写调用它的内容。省略它就会漏掉 ZK 特有且难以猜测的部分。因此,至少为页面上实际存在的某个元素添加一个可用的处理器,例如保存按钮、行选择或搜索框,并让它操作标记中已有的值。
现在处理行为,之后处理数据。 无论页面是静态的还是由模型驱动的,这里编写的都是页面的行为。在由模型驱动的页面上,此时数据仍在标记中,应留在那里,直到布局确定——因此不要调用 setModel(),不要为绑定的 model 提供 getter,现在还不到时候。这两项都将在提取阶段加入(参见步骤 2 中的 由模型驱动的页面),届时会再次执行此步骤来补上它们。现在就写入数据,是最容易在不知不觉中丢失字面量阶段的做法:步骤 5 会渲染真实行,而布局将永远无法根据你检查过的内容进行评估。
图表除外。 <charts> 没有字面量形式,因此首次渲染时就要在此处加入其模型,而页面上的其他内容仍保持字面量形式——参见步骤 2 中的 图表没有 Pass 1。为了遵守上面的要求而移除图表数据,会留下一个空图表,且不会报告任何错误。
控制器生成指南
-
模式一致性:
- MVVM 模式使用 ViewModel。
- MVC 模式使用 Composer。
-
实现细节:遵循 references/controller-guidelines.md 中的技术要求。
-
完成前,对照 ZUL 检查连接配置。 使用
--controller重新运行验证器,以启用第 7 层:uv run <skill-base-dir>/scripts/validate-zul.py <path-to-zul> --controller <path-to-controller>它会报告两个此工作流中其他任何环节都无法发现的缺陷——其他层看不到,第 5 步的渲染也看不到:
@Wire字段的类型与其所指 ID 对应的组件类型不同。这种情况可以编译通过,也能通过所有验证层,渲染结果也正确,但首次使用该字段时会抛出ClassCastException。此前一次运行在<a>上使用了@Wire Label,直到重新检查自己的 composer 才发现问题。@Wire字段所指的 ID 未由 ZUL 中的任何组件声明。该字段会保持为 null,首次使用时会抛出NullPointerException。
在无法确定的情况下,第 7 层会保持静默——例如组件类之间存在继承关系的组件系列(
Textbox/Combobox、Checkbox/Radio、Box/Hbox、Row/Group、Listitem/Listgroup、Button/Combobutton)、Component这类基类字段类型、集合、非#id选择器,以及包含<include>的页面。第 7 层保持静默,并不保证连接配置正确;这只表示它无法证明存在错误。
MVC 模式 - Composer 类
MVVM 模式 - ViewModel 类
完整示例与模式
对于看板或仪表板等复杂 UI 模式,以及完整的模板示例,请参阅 references/use-case-guidelines.md。
第 5 步:预览与自我审查
将完成的页面渲染为图像,查看图像,并根据第 1 步收集的需求进行检查。第 2–4 步只能看到标记;只有这一步能看到页面的实际样子。
如果用户只要求预览,就没有第 1 步的答案可供对照——因此请根据页面自身的标记和用户所说的预期来评估渲染结果,描述你看到的内容,并报告图像路径。下方的 需要修复的内容 列表仍会告诉你哪些缺陷值得指出;这并不意味着可以编辑用户要求你查看而非修改的文件。
从此技能的基础目录运行预览脚本(与第 3 步使用相同的约定):
uv run <skill-base-dir>/scripts/preview-zul.py <path-to-zul-file>
例如,如果技能基础目录是 ~/.claude/skills/zul-writer/,且页面位于 Maven Web 应用中,请运行:
uv run ~/.claude/skills/zul-writer/scripts/preview-zul.py src/main/webapp/index.zul
图像保存位置:当前工作目录。 如果未指定 --out,PNG 文件会写入当前工作目录,并以页面命名——index.zul 会生成 ./index-preview.png——而 SCREENSHOT: 行会报告确切路径。请将图像保留在那里。这张图像不是临时文件;它是整个工作流唯一的视觉产物,是用户打开后查看页面是否符合要求的内容,因此应保存在用户可以点击、保留或删除的工作成果旁边。将其发送到临时目录或草稿目录,只会让图像藏在用户必须另行询问、之后也无法找到的路径下。重新渲染会覆盖同一文件,因此修复过程中路径始终有效,最后报告的路径也仍然正确。只有在用户指定目标位置,或语料库/CI 任务需要将图像收集到某处时,才传入 --out。
如果此会话编写了页面控制器,请追加 --run-controllers(执行前请阅读下一段——它会运行项目代码):
uv run ~/.claude/skills/zul-writer/scripts/preview-zul.py --run-controllers src/main/webapp/index.zul
何时传入 --run-controllers。 如果本次会话编写了页面的控制器(步骤 4 的 composer 或 ViewModel),就传入此参数:其中的示例数据由你提供,运行控制器才能将骨架截图变成可供评审的截图;此标志还会让绑定值、模型绑定行以及由 composer 填充的标签显示为实际内容。它也是检查提取效果的渲染方式:将页面中的字面值移入控制器(步骤 2)后,通过它可以确认页面看起来仍与你批准的版本一致。对于你未编写控制器的页面,不要传入此参数——该标志会从项目的类路径中执行任意项目代码(构造函数、服务调用以及 doAfterCompose 执行的任何操作),因此必须逐次渲染显式启用,绝不能作为默认设置。如果控制器尚未编译,请先构建(mvn compile / gradle classes);当类路径中没有已编译的类时,脚本会发出警告。只有在正常运行的页面持续耗时过长时,才添加 --controller-timeout <seconds>(整个渲染的默认时限为 10 s)。
视口:--width、--height、--full-page。 默认视口为 1280x900;输出中的 SIZE: 行始终会说明渲染实际使用的视口。
- 匹配原型的宽度。 用户提供了截图或原型时,传入
--width,使其接近图像的像素宽度,这样两张图才能进行同类比较——1600 px 的原型意味着传入--width 1600。拿默认的 1280 宽度与更宽的原型进行渲染,会产生与你无关、而非页面本身的问题,你也会把时间花在修复这些问题上。匹配布局宽度,而不是文件的像素数:高 DPI 导出图的宽度减半;除非你确实想测试窄视口,否则不要照搬远低于 1024 的缩略图宽度——极窄的渲染会人为制造clipped-text问题,而同一标记在桌面宽度下并不会产生这些问题。 - 页面内容超出首屏时使用
--full-page——例如长表单、纵向堆叠的报告,以及任何需要滚动的页面。它会将整个可滚动页面拼接到 PNG 中。 - 页面采用纵向 flex 外壳时使用
--height。 根区域为vflex的页面按设计恰好与视口等高,因此--full-page无法展示更多内容;应改为增大--height。hflex控制的是宽度,不会产生这种效果——hflex页面与其他页面一样会超出首屏,此时应使用--full-page。如果--full-page截图的高度恰好等于SIZE:中的视口高度,就说明页面尺寸由 flex 决定,而不是内容被截断。
uv run ~/.claude/skills/zul-writer/scripts/preview-zul.py --width 1600 --full-page --run-controllers src/main/webapp/index.zul
脚本会解析项目的 ZK jar(Maven、Gradle,或者文件不属于任何项目时使用标准 ZK),通过 ZK 自带的引擎渲染页面,并写入 PNG。需要 Java 17+ 以及 Google Chrome 或 Microsoft Edge。 首次使用时,脚本会下载渲染辅助程序(zk-preview-launcher.jar,约 500 KB),验证其 SHA-256,并将其缓存在 ~/.cache/zul-writer/ 下;之后运行无需联网。
然后,使用图像读取工具读取 PNG,并与步骤 1 中的回答进行比较。如果用户是从截图或原型开始的,也要重新读取该图像,并将两张图并排比较。
无法生成预览时
如果脚本退出码为 2,并打印一行以 PREVIEW_SKIPPED: 开头的内容,表示无法解析 ZK jar、没有 Java 17+、没有浏览器,或者无法下载辅助程序。这并非 ZUL 的缺陷。 用一行说明情况——"已跳过渲染预览:<原因>"——然后照常完成任务。
绝不要描述你没有看过的截图,也绝不要把跳过预览当作预览通过。
NEXT: 行会说明如何启用预览。如果看起来可以在此解决,就重试一次——追加 --debug 会将已解析的类路径、辅助程序的每条命令行以及渲染器自身的输出打印到 stderr(stdout 保持不变),通常足以查明原因。然后停止并报告预览跳过的情况,无论结果如何。
首先查看 CONTROLLERS: 行
输出中有一行,在这行上,下方的判定规则会反转:
| 行 | 图像内容 | 阅读方式 |
|---|---|---|
CONTROLLERS: executed | 控制器已运行:绑定的是真实值,模型行是真实数据,Composer 输出也是真实内容 | 无法判定的内容会减少——绑定字段为空就是缺陷 |
CONTROLLERS: skipped (isolated) | 默认状态:没有 Composer,也没有 ViewModel | 表达式文本变暗且显示占位行,属于正常行为 |
CONTROLLERS: failed → isolated | 你请求运行控制器,但控制器运行失败,因此改为提供隔离渲染结果 | 按隔离规则查看,并参见下方新增的 需要修复的内容 条目 |
请先阅读 LAYOUT: 块
浏览器对这些内容进行了测量,因此它们是事实,而非观点——请在打开 PNG 之前先阅读。
每行的格式为 规则 | 定位器 | 测量结果:
LAYOUT: 3 findings
- zero-size | a[label="Settings"] | 0x0 with text but no box
- clipped-text | a[label="Documents"] | text needs 77px, box is 48px
- viewport-overflow | grid.gp-wide | page scrollWidth 2005 > viewport 1280; widest offender 2000px
当组件有 ZUL id 时,定位器就是该 id(label#breadcrumbCurrent);否则,它是组件加上一个可用于区分的属性(a[label="Settings"]),或组件的样式类(grid.gp-wide)。它绝不会使用生成的 id,因此你总能在自己的标记中找到它。
| 规则 | 含义 | 修复方法 |
|---|---|---|
clipped-text | 文本超出裁剪它的框,因此部分内容被截断 | 加宽该框(width/hflex)、允许文本换行,或缩短文本 |
zero-size | 组件完全不占空间,其内容不可见 | 该组件或其祖先缺少 height/vflex,或者存在 width: 0 样式规则 |
escapes-parent | 可见内容溢出会进行裁剪的祖先,因此超出的部分被截断 | 为父级留出空间,或阻止子级溢出 |
viewport-overflow | 页面宽度超出视口,因此需要水平滚动条;该行会标出最宽的超出项 | 移除所标元素的固定宽度,或将其设为 hflex/百分比 |
icon-not-rendered | 字体图标会显示为空白框:虽然字形存在,但浏览器解析出的字体栈无法提供该字形 | 将图标类放在能保留图标字体的承载元素上——例如 iconSclass 或普通容器,而不要放在其自身的 font-family 优先级更高的元素上 |
literal-rows-discarded | 标记中写明的行没有显示在页面上,因为设置模型会丢弃这些行。通常是提取数据时移走了数据,却留下了旧行 | 删除这些字面量 <listitem>/<row>/<treeitem> 元素。现在,模型是页面中唯一的行数据来源 |
literal-rows-discarded 是唯一无法通过 PNG 检查的规则,因为它涉及页面不包含的内容。页面渲染正确——模型中的所有行都存在——但标记中仍留有完全不显示的行,导致之后编辑这些行的人做了修改却毫无效果,也不知道原因。该规则会通过两种方式触发:仅根据标记检查时,model 属性与字面量行并存;以及根据渲染结果检查时,Composer 的 setModel()(位于 Java 中,任何 ZUL 检查都看不到它)替换了这些行。分页、折叠的树节点和未选中的标签页都会合理地让行不显示;规则测量时已考虑这些情况,因此这里的发现不属于这些情况。
采取行动前,请了解以下三点:
- 没有需要报告的内容时,整个块都会省略。 没有
LAYOUT:行,就表示审计已运行且没有发现问题。 - 在此循环中,它绝不会改变退出代码。 它只报告问题,不强制执行;
STATUS: ok仍表示页面已渲染。只有 CI 中的--fail-on-layout会因发现问题而返回 exit 4,即便如此,也会先打印STATUS: ok。 - 它检查的是整个文档,而非仅检查捕获的图像——包括首屏以下的所有内容,无论是否使用
--full-page。因此,发现的问题可能涉及截图中看不到的内容。请相信测量结果;如果想查看该内容,请使用--full-page重新渲染。
在 CONTROLLERS: skipped (isolated) 下,针对占位文本(狭窄列中的 prod.price)发现的 clipped-text,是根据占位内容而非真实数据测量的——在因此加宽列之前,请先使用 --run-controllers 渲染结果进行检查。
STATE: — 已交付内容的折叠状态
本区块中的内容都不是缺陷,因此不会列在 LAYOUT: 中。一个渲染为关闭状态的 groupbox,可能恰好符合设计,也可能与设计相反,而页面无法告诉你是哪一种——预期状态只存在于模型图或用户的要求中。因此,渲染结果会列出各项状态,由你进行比对:
STATE: 3 collapsible (1 open, 2 closed)
- open | groupbox#gbFilters
- closed | groupbox[title="Advanced"]
- closed | treeitem#tiSettings
逐行对照设计检查。 这正是本区块存在的全部理由。凭肉眼判断折叠状态,意味着要注意某个没有显示在屏幕上的内容,而这种检查最容易被跳过——某次评估运行中,侧边栏树完全展开,与显示折叠状态的模型图不符,却没有报告任何问题。两份简短列表可以相互比对,而截图和记忆却不行。
这里列出四种组件:groupbox、nav、detail 和 treeitem——它们的内容都可能被隐藏,同时页面仍完全按照要求运行。没有子项的 treeitem 不会列出,因为叶节点无法折叠。刻意不列出的内容包括:未选中的标签页、分页网格、已滚动的区域。它们也会隐藏内容,但隐藏内容正是它们的用途;若将它们列出,反而会淹没那些可能以错误状态交付的四种组件。
操作前请了解以下三点:
- 关闭项的后代也不会出现在此区块中。 ZK 根本不会渲染已折叠分支的子项,因此其中的
treeitem无法列出——它尚不存在。你看到的是页面上当前所有内容的状态,这也是用户能看到的全部内容。 - 如果页面没有任何可折叠内容,就会完全省略此区块,与
LAYOUT:一样。没有区块,就无需考虑这个问题。 - 这里的差异确实是缺陷,修复方法是使用
open属性——只需一个属性,不必更改布局。如果设计和用户要求都没有说明组件应处于哪种状态,就不要改动,并说明原因:没人要求你更改的open状态,不该由你来改。
WARNINGS: — 控制台和客户端错误
WARNINGS: 块中的三种条目来自浏览器,而不是启动器:
| 条目 | 含义 |
|---|---|
console error: <text> | 页面自身的 JavaScript 调用了 console.error — 可能来自 <zscript>、n:script 或某个小部件的客户端代码 |
console warning: <text> | 同理,来自 console.warn。ZK 的客户端引擎确实会发出少量此类警告(例如未加载的区域设置、意外过大的 AU 批次) |
ZK client error: <text> | ZK 的客户端引擎发出了错误 — 例如未知小部件、挂载失败、缺少 mold。服务器运行正常:STATUS: ok,退出码为 0 |
ZK client error: 条目最值得重视。它是唯一能表明服务器渲染的页面实际上未能在浏览器中正常显示的信号,而且不会反映在退出码中。
有几项限制值得注意:
- 它只涵盖首次绘制 — 包括截图完成前的所有内容。之后的 AU 往返过程中出现的错误不会被捕获,因此该块为空并不能证明会话没有问题。
- ZK 客户端错误来自页面上的错误框,脚本会从 DOM 中读取它,因为 ZK 不会将这些错误写入控制台。错误框是追加到页面中的真实元素 — 一个浅红色面板,标题为
N Errors,位于顶部中央,并带有重新加载和关闭图标 — 因此通常会出现在 PNG 中。图像中出现无法解释的红色面板,指的就是同一个问题,并非需要修复的标记。错误框中的N会统计所有触发的消息,而WARNINGS条目会去重,因此同一错误重复出现时,两处的数字不同是正常的。 - 浏览器的
Failed to load resource:行会被有意忽略。 每个页面都会因缺少 favicon 而产生一行,而且该行不包含 URL;若保留,就会导致每个正常页面都出现一条问题记录。真正加载失败的内容都会有单独的WARNINGS条目并注明 URL —~./类路径缺失时为ZK resource not served,docroot 中缺少文件时为page asset not found。
--debug 会将所有控制台级别的信息打印到 stderr,包括此块会过滤掉的级别 — 页面行为异常但此块为空时,请使用它。
如果你要编写脚本,而不是阅读这些内容
--report json[:<path>] 会将本次运行写入一个 JSON 对象 — 默认放在 PNG 文件旁边,后缀为 .json — 这样 CI 或语料库任务就可以对结构化运行结果进行比较,而不必解析这些行。
stdout 只会增加 REPORT: <path>;上文所有块都保持不变。完整的架构,以及不同退出码对应会填充哪些键,见 references/preview-guidelines.md。
手动执行第 5 步时,不要传入此参数。 运行脚本的同一次工具调用已经返回了这些 stdout,因此该文件只会为相同信息增加一次额外往返。
需要修复的内容
判断结构,而不是像素或数据。只修复以下问题:
- 出现错误页面,而不是目标页面(脚本以 1 退出,并打印
PHASE、MESSAGE和LOCATION)。这是实际的 ZUL 错误——请在报告的位置修复。 - “Unknown component
<x>”——定义该组件的 jar 不在类路径中。标签可能有拼写错误,也可能缺少附加依赖;请询问用户,不要删除该组件。 - 与步骤 1 要求相比,缺少或多出区块。
- 区域位置错误——侧边栏显示在内容下方、缺少页眉、选项卡内容位于其
<tabpanel>外部。 - 组件选择错误——数据表显示为一串普通标签,表单字段不是要求的输入类型。
- 布局损坏——内容被裁切或溢出、本应适配页面却出现水平滚动条、区域高度缩为 0、组件互相重叠、
hflex/vflex明显未生效。LAYOUT:块会准确列出这些问题及相关组件和测量值——请先修复这些问题,再处理任何需要肉眼判断的问题。 - 本应使用 ZK 组件,却出现未经样式处理的原始 HTML。
- 页面要求的资源未能加载。 预览服务器会提供文档根目录,因此
WARNINGS行中指出缺失图像、样式表或脚本,表示该文件在对应路径下不存在——这是确实需要修复的问题,不是预览限制。先检查路径,再查看DOCROOT:行,因为猜测错误的文档根目录会让正确路径看起来有误。另一种情况是~./URL:这表示类路径中缺少 jar,因此请询问用户是否缺少依赖,不要修改标记。 - 字体图标显示为空白框——
LAYOUT:中会以icon-not-rendered报告,并指出无法提供该字形的字体栈。该类位于覆盖图标字体的载体上;references/ui-to-component-mapping.md 中的 Icons 部分会说明应将其移到哪个载体上。这只需修改标记中的一个词,而在评估中曾有三次被误诊为其他问题,直到测量结果出来才得到确认——请相信检测结果,不要凭空猜测 Web 字体的问题。 ZK client error:条目指出未知组件或挂载失败——附加组件 jar 的客户端 JS 包缺失,尽管服务器端类已解析成功,因此页面解析后显示异常。请检查WARNINGS中的 404 条目和类路径,并询问用户是否缺少依赖——绝不要因此重写正常的标记。LAYOUT:中出现literal-rows-discarded——删除其中指出的字面量行。这是唯一一种无法通过查看图像来确认的问题,因为图像中的页面是正确的;缺陷在于渲染为空的标记。这通常是提取操作移动了数据,却留下旧行造成的,因此删除前请确认控制器确实会提供这些数据。STATE:行与设计不符——设计稿显示为关闭状态的 groupbox、nav、detail或树分支却处于打开状态,或反之。只需修复open属性,其他内容不要动。如果设计稿和请求都没有说明应处于哪种状态,就保持不变并说明原因。CONTROLLERS: failed → isolated——阅读WARNINGS条目:其中会指出失败的类和首条原因行。控制器异常、缺少类或超出预算,是控制器(或构建缺失)的问题,不是 ZUL 的问题。请在控制器中修复后重新渲染,或报告问题——不要通过将值硬编码到标记中来规避。退出代码仍为 0,截图仍然有效;它只显示了隔离渲染结果。
当图像显示了缺陷,却没有显示其原因时
图标本该在的位置却是一个空框。找不到的组件。没人要求的颜色。与设置值不符的宽度。在每种情况下,PNG 都能证明出了问题,却无法说明原因;而重新渲染得到的还是同一张 PNG。
检查元素,而不是重新渲染。 --probe '<css-selector>' 会报告所有匹配项,展示浏览器实际构建出的内容:起始标签、测量到的框,以及触发这些缺陷的计算样式。它读取的是已有的渲染结果,因此不会额外耗时。
uv run --with playwright preview-zul.py page.zul --probe '[class*="z-icon-"]'
LAYOUT: 发现项已经用 CSS 定位器标明了元素;将该定位器直接粘贴到 --probe 中,就能查看测量结果为何如此。当你还不知道该查询什么时,--dump-dom 会将整个挂载后的 DOM 写入文件,供你使用 grep 搜索;之所以写入文件而不是直接输出内容,是因为数据量大的页面可能会达到数百 KB。
这很重要,因为 DOM 是 ZK 页面作为标记存在的唯一地方。服务器返回的响应是 zkmx([...]) 引导代码,只是照搬你的 .zul;每个类名、字体和框都是之后在浏览器中构建的。
无法从此图像中判断的内容
预览只会渲染首次绘制。当 CONTROLLERS: skipped (isolated) 或
failed → isolated 时,也不会运行任何 ViewModel 或 Composer,以下所有现象都是
渲染器的正常表现。不要“修复”它们,不要将其报告为缺陷,也不要因此触发重新渲染:
- 绑定值显示为变暗的表达式文本(例如文本框中直接显示
vm.customer)——ViewModel 不会运行。这是渲染器的正常表现,也正因如此,新建由模型驱动的页面时要先使用字面数据(步骤 2):你无法根据页面实际不会显示的文本来衡量布局。 <grid>/<listbox>/<tree>中model绑定的占位行(例如行中显示each.product)——变暗的示例行会保留组件的实际几何尺寸。<include>的src绑定时,整个区块都不显示——这种情况不会显示占位内容。常量字面值(src="@load('~./page.zul')")会正常包含;ViewModel 提供的任何值(src="@load(vm.page)")都会让src保持未设置,因此 include 不会贡献任何内容,你看到的是一片空白,而不是变暗的文本。添加硬编码的src来“修复”空白会破坏实际页面。- Composer 或 ViewModel 会填充的任何内容——默认值、初始选择、计算出的标签、i18n 文本。此处的
apply="..."composer 不会执行任何操作。 - 任何需要服务器往返的操作——按钮点击、分页、排序、树展开、选择高亮、由事件打开的
<window>或弹出窗口。这里只有首次绘制状态。客户端的w:处理程序会运行,<zscript>也会运行。 - 主题 jar 不是项目依赖项时的主题相关颜色和间距。
- 精确间距、字体渲染、亚像素对齐,或仅仅与样图接近的颜色。
- 样图中的数据内容——示例数据可能不同。比较 UI 的形态,而非其中的值。
--full-page图像的高度。--full-page不会调整浏览上下文大小——Playwright 会在之后拼接出一张更高的 PNG——因此LAYOUT:发现结果以及每一项hflex/vflex测量都对应SIZE:视口,而不是图像高度。请参阅 references/preview-guidelines.md 中的 视口。--full-page截图中position: fixed元素的位置。它固定在视口上,而拼接后的图像高于视口,因此写着bottom: 40px的浮动操作按钮会落在SIZE:高度上方 40px 处,出现在图像中间。页面本身没有问题,问题出在截图。实测:在高度为 900 的视口中,bottom: 40px的 FAB 在高度为 1277 的 PNG 中渲染于 y=798 处。不要追着这个现象改,也不要通过移动其他元素来“修复”它——请改用视口大小的渲染结果进行检查。
在 CONTROLLERS: executed 模式下,上述前四条不再适用——变暗的表达式
文本、占位行、缺失的绑定 src 区块,以及“Composer 或 ViewModel 会填充的任何内容”现在都会真实渲染。在此模式下,绑定字段渲染为空、数据表没有行,或区块仍然缺失,都是缺陷——可能是控制器没有提供值,也可能是 ZUL 绑定使用了错误的属性名。从 “任何需要服务器往返的操作” 开始的内容仍适用于两种模式。
带空状态的页面有两种首次渲染,而你只渲染了其中一种
任何由 empty vm.something 控制的内容——主从窗格、“无结果”消息、对话框正文——都有第二种布局,而首次渲染都不会触及它,并且缺陷往往更容易藏在那里,因为没人会检查那一半。
本技能自带的 assets/master-detail-mvvm.zul 就有两个这样的缺陷。它通过了验证,渲染也正常;但两者都只存在于选中状态中。详情窗格绑定了 vm.selectedItem.description,而它的配套类 MyViewModel.Item 并没有这个属性——这不会让内容空白,而是会抛出 PropertyNotFoundException 并导致整个控制器崩溃,因此页面会回退到 CONTROLLERS: failed → isolated,并显示占位内容。另一个问题是,visible 设置在 hflex="2" 窗格本身上,因此在首次点击之前,窗格的宽度会被分配给主列表;点击后又被收回:未选中任何内容时测得宽度为 1265px,选中一行后为 422px,这种 3 倍的跳变,所有静态检查层和首次渲染都无法发现。
只需在控制器中增加一行,就能安排第二次渲染:先选中一行(在 @Init 中设置 selectedItem = items.get(0)),渲染,然后再将其还原。列表可能为空的页面、步骤 1 之后的向导,或初始处于折叠状态的面板也应这样处理。检查两种状态下窗格的几何尺寸,而不只是内容——对容器运行 --probe 会报告宽度,如果宽度在两种状态之间发生变化,那就是缺陷。
需要几轮
一轮包括:渲染、阅读、从待修复项中列出缺陷、修复缺陷,然后重新运行步骤 3 的验证。三条规则决定是否需要再来一轮,每条规则都能发现其他规则看不到的问题。
- 每个缺陷最多修复两次。 如果针对某个缺陷的第二次编辑后,缺陷仍然存在,就停止修改它。两次编辑都没有效果,说明你设想的原因并非真正原因;第三次编辑改动的就会是标记,而不是缺陷,而被改动的标记部分原本是正常的。改为诊断原因(这不占用轮次,见下文)或报告问题;带着测量结果再回来,不算第三次猜测。这条规则可以阻止盲目循环:针对同一个缺陷,基于不同猜测接连反复编辑。
- 只要缺陷列表在缩短,就继续。 一轮至少修复一个缺陷且没有引入新缺陷,才值得进行下一轮。如果一轮没有修复任何缺陷,就停止——这与下文的诊断规则相同。测量阶段不必在脑中记住数量:每次渲染都会输出
LAYOUT: N findings。 - 每次处理最多四轮修复。 这是针对前两条规则无法涵盖的情况所设的兜底限制——缺陷此消彼长,每轮修复一个缺陷,又引入另一个缺陷。六次测量运行中,没有任何页面需要超过三轮,所以达到四轮本身就是一个发现:要说明这一点,不要悄然停止。第 1 阶段(原样布局)和第 2 阶段(提取后)各有独立的四轮额度,因为提取后才出现的缺陷,其原因是提取,而不是布局。
- 预算计算的是编辑次数,不是渲染次数。 为了弄清问题所在而再次渲染——查明是哪个元素、哪项测量,或两种可能原因中的哪一种——不算修复轮次,也不受次数限制;
--probe或--dump-dom运行也不算。此时尚未做任何改动,因此还谈不上连续两次出错;这些规则要防止的是凭猜测编辑,然后又基于下一个猜测再次编辑。原因仍未查明就停止,可能导致真正的缺陷被交付,或被归咎于预览。诊断有不同的停止规则,不是次数限制:当下一次渲染无法告诉你任何上一次尚未告诉你的信息时,就停止。 有一次评估运行花了六次渲染,才查明某个图表动画会裁切每张截图;这完全合理——如果次数限制让人觉得这样做违反了规则,那说明衡量错了对象。 - 只修复列表中的完整缺陷。 如果列表为空,或剩下的所有项目都属于 无法判断的内容,那这个页面就已经足够好——用一句话说明,然后停止。“足够好”意味着第 1 步中的每项要求都清晰可见,位于正确区域,采用正确类型的组件,并且没有任何内容被裁切或重叠。
- 由模型驱动的页面,要把第 1 阶段的修复轮次用于原样版本。 在数据仍位于标记中时先确定布局,然后再提取,并使用
--run-controllers重新渲染一次。这次渲染不算修复轮次——它检查的是提取结果,而非布局;如果此后页面看起来不同,说明改变它的是提取过程。此时发现的任何问题都属于第 2 阶段,并使用第 2 阶段的修复额度。 - 不要仅为视觉差异而编辑 ZUL。 对照样稿追逐像素会耗费轮次,并导致原本正常的标记退化。
- 如果一轮结束后页面比开始时更差,就撤销该轮编辑并停止。 修复导致退化,说明你还不了解这个缺陷;已有的页面比下一次猜测更有价值。
- 停止时如果仍有未解决的缺陷,请告知用户问题是什么,以及你尝试过什么。不要继续渲染。
- 说明用了多少修复轮次,以及还有哪些问题未解决。 不告知用户预算,就无法事后核查——这与第 1 步说明所用默认值的原因相同。也请报告最终图像路径,以便用户自行查看页面。
有关类路径解析详情、docroot 规则,以及每种 PREVIEW_SKIPPED 原因的含义,请参阅 references/preview-guidelines.md。