@qilitt-mickey/vue3-temp-skill 1.2.2 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -36,7 +36,7 @@ tags: [vue3, typescript, element-plus, pinia, vite, unocss, crud, demo, code-qua
36
36
  node <技能>/scripts/check-landing.mjs --project <项目根>
37
37
  ```
38
38
 
39
- **六项全过(exit 0)是交付前置条件**:契约存在 → 契约完整 → 三份 CSS 生成 → 被 import → 角色类挂载 100% → 布局类挂载。
39
+ **八项全过(exit 0)是交付前置条件**:契约存在 → 契约完整 → 契约结构合规 → 三份 CSS 生成 → 被 import → 角色类挂载 100% → 布局类挂载 → CSS 规则覆盖契约全部声明。
40
40
  **任何一项断掉,都会出现「CSS 写了但页面没变化」——先修断点,不要目视猜。**
41
41
 
42
42
  ## 能力组路由
@@ -56,7 +56,7 @@ node <技能>/scripts/check-landing.mjs --project <项目根>
56
56
  | `files-and-editors` | 附件、预览、下载、富文本、二维码 | `file-management.md`、`download-export.md`、`rich-text.md`、`qrcode-barcode.md` |
57
57
  | `realtime-and-mobile` | WebSocket、移动端、微信、验证码、动效 | 对应专项 reference |
58
58
  | `data-compare` | 编辑页变更对比和汇总 | `data-compare.md` |
59
- | `design-apply` | **视觉契约落地**:落地自检 → 读契约 → 选 adapter → 生成三份 CSS → 挂载 → 覆盖验证。**主入口 `design-apply.md`,adapter 见 `adapters/`(做到哪一步读哪一份)** | `design-apply.md`、`adapters/`、`mapping/`、`scripts/check-landing.mjs` |
59
+ | `design-apply` | **视觉契约落地**:落地自检 → 读契约 → 选 adapter → **`scripts/generate-css.mjs` 确定性生成三份 CSS** → 挂载(含按契约 `structure` 重建页面内容)→ **`scripts/verify-render.mjs` computed style 逐点比对** → 差异清零。**主入口 `design-apply.md`,adapter 见 `adapters/`(做到哪一步读哪一份)** | `design-apply.md`、`adapters/`、`mapping/`、`scripts/check-landing.mjs`、`scripts/generate-css.mjs`、`scripts/verify-render.mjs` |
60
60
 
61
61
  ## 工作流
62
62
 
@@ -93,7 +93,7 @@ node <技能>/scripts/check-landing.mjs --project <项目根>
93
93
 
94
94
  使用者默认**不懂设计也不懂代码**,交互遵守四条:
95
95
 
96
- 1. **全自动默认**:识别为视觉契约后**直接开始执行全流程**(落地自检 → 读契约 → 选 adapter → 生成三份 CSS → 挂载 → 验证),不等用户逐步确认。
96
+ 1. **全自动默认**:识别为视觉契约后**直接开始执行全流程**(落地自检 → 读契约 → 选 adapter → 生成三份 CSS → 挂载 → 验证 → 视觉验收),不等用户逐步确认。
97
97
  2. **契约缺失 = 硬阻断,不允许"边猜边改"**:`check-landing.mjs` 报 `contract-found FAIL` 时**必须停下**,用大白话报告「还没拿到设计文件」并给出下一步。**严禁在契约缺失的情况下凭理解直接改项目样式**——那是旧体系的失败模式:改了一堆 CSS 却没有任何东西对应,最后只有个别地方碰巧变了。
98
98
  3. **契约存在但 `roles-mounted` 未 100% = 不算完成**:角色类没挂满,页面上就只会有个别角色生效(常见症状:只有菜单颜色变了)。必须按 adapter 的「角色类挂载方式」补齐后重跑自检。
99
99
  4. **术语不对用户出现**:对用户不说"契约 / 角色 / 令牌 / adapter / 区域 / 自检"——说"设计文件 / 样式规则 / 全局变量 / 组件适配 / 页面区域 / 检查"。这些词只出现在内部文档与交付说明里。
@@ -104,7 +104,7 @@ node <技能>/scripts/check-landing.mjs --project <项目根>
104
104
 
105
105
  - 对照 `references/code-quality.md` 检查类型、组件复用、API、路由、权限、安全、异常反馈、响应式和可演进性。
106
106
  - 在目标项目执行 `pnpm lint:fix`、`pnpm type-check`;改动构建配置时再执行 `pnpm build`。
107
- - **设计契约落地时追加目视对照**:打开页面与契约 `meta.source.mockup` 指向的效果图并排看;有差异 → **改契约 → 重新生成 CSS**(改契约远比改代码便宜)。不建期望值文件、不跑采集脚本、不做逐点 diff 报告。
107
+ - **设计契约落地时追加视觉验收**:用无头浏览器对契约每条声明做 `getComputedStyle` 逐点比对(期望源就是契约 JSON 本身);差异定位顺序「挂载遗漏 → 穿透/选择器通道错 → 提权被盖 → 契约值本身错」,前三者修落地侧,最后一种 **改契约 → 重新生成 CSS**(改契约远比改代码便宜)。全部命中后截图与 `meta.source.mockup` 并排留档;不建期望值文件、不跑采集脚本、不做长期 diff 报告。
108
108
  - 最终说明:命中能力组、修改文件、功能验证、命令结果、未解决问题和下一步。
109
109
 
110
110
  ## 视觉契约落地(design-apply 架构要点)
@@ -170,5 +170,5 @@ node <技能>/scripts/check-landing.mjs --project <项目根>
170
170
  - 功能:...
171
171
  - `pnpm lint:fix`:通过 | 失败(原因)
172
172
  - `pnpm type-check`:通过 | 失败(原因)
173
- - 视觉契约落地(本轮有契约输入时必填,无则 N/A):三份 CSS 已生成 → 目视对照差异 N 处(已改契约重新生成 / 待确认)
173
+ - 视觉契约落地(本轮有契约输入时必填,无则 N/A):八项自检全过 → 视觉验收 computed style 比对差异 N 处(已改契约重新生成 / 待确认)→ 截图已留档
174
174
  ```
@@ -7,30 +7,30 @@
7
7
  "adapter": "vue-element-plus",
8
8
 
9
9
  "layoutHost": {
10
- "path": "src/layout/lay-admin/index.vue",
11
- "note": "退化为占位容器:只提供插槽,不参与布局决策。页面骨架由 ui-layout.css 的 .ui-page 网格决定。"
10
+ "path": "src/layout/index.vue",
11
+ "note": "真实布局宿主:根节点挂 .ui-page 与角色类,四区域组件各自挂 .ui-region-* 与角色类。页面骨架由 ui-layout.css 的 .ui-region-* 决定,布局组件模板不改结构。"
12
12
  },
13
13
 
14
14
  "regions": {
15
15
  "sidebar": {
16
- "scope": ".lay-sidebar",
17
- "var": "--ui-sidebar-w",
18
- "collapsedVar": "--ui-sidebar-w-collapsed",
16
+ "scope": ".sidebar-container",
17
+ "var": "--sidebar-width",
18
+ "collapsedVar": "--sidebar-width-collapsed",
19
19
  "fallback": "208px"
20
20
  },
21
21
  "header": {
22
- "scope": ".lay-header",
23
- "var": "--ui-header-h",
22
+ "scope": ".navbar",
23
+ "var": "--header-height",
24
24
  "fallback": "54px"
25
25
  },
26
26
  "tabs": {
27
- "scope": ".lay-tabs",
28
- "var": "--ui-tabs-h",
27
+ "scope": ".tags-view",
28
+ "var": "--tabs-height",
29
29
  "fallback": "40px"
30
30
  },
31
31
  "content": {
32
- "scope": ".lay-content",
33
- "var": "--ui-content-padding",
32
+ "scope": ".app-main",
33
+ "var": "--layout-content-padding",
34
34
  "fallback": "24px"
35
35
  }
36
36
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qilitt-mickey/vue3-temp-skill",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "type": "module",
5
5
  "description": "Vue 3 企业级中后台项目开发规范技能包 — core-kernel 架构、按需功能模块、视觉契约落地",
6
6
  "bin": {
@@ -15,19 +15,26 @@ description: Vue 3 + Element Plus 适配。提供深度选择器语法、组件
15
15
 
16
16
  **本项目建议**:`ui-roles.css` 写成**全局样式文件**(不 scoped),避免每个页面重复引入。
17
17
 
18
- ## 内层穿透语法
18
+ ## 内层穿透语法(双通道,选错 = 规则整条静默失效)
19
19
 
20
- 契约的 `inner[]` 落到组件内部文本盒时,需要穿透到组件库内部元素:
20
+ 契约的 `inner[]` 落到组件内部文本盒时,需要穿透到组件库内部元素。**先看样式落点文件的类型,再选语法通道**:
21
21
 
22
- | 角色 | 穿透目标 |
23
- |---|---|
24
- | `table.headerCell` | `:deep(.cell)` |
25
- | `table.cell` | `:deep(.cell)` |
26
- | `shell.sidebar.item` | `:deep(span)` |
27
- | `filter.input` | `:deep(input)` |
28
- | `shell.tabs.item` | 直接作用在文本节点(无需穿透) |
22
+ | 落点文件 | 语法通道 | 示例 |
23
+ |---|---|---|
24
+ | 全局 CSS(`ui-roles.css`,本项目默认) | **普通后代/子代选择器**——禁止 `:deep()` | `.role-table-header-cell .cell { … }` |
25
+ | SFC `<style scoped>` | `:deep()` | `.role-table-header-cell :deep(.cell) { … }` |
26
+
27
+ **角色 → 内层目标对照**(目标由组件库 DOM 结构决定,不由契约决定):
29
28
 
30
- **规则**:`host[]` 作用在组件根元素(加角色类即可);`inner[]` 作用在根元素内部——**用 adapter 给出的穿透目标定位**。目标选择器由组件库 DOM 结构决定,不由契约决定。
29
+ | 角色 | 全局文件写法 | scoped 写法 |
30
+ |---|---|---|
31
+ | `table.headerCell` / `table.cell` | `.role-… .cell`(EP 单元格内层文本盒) | `:deep(.cell)` |
32
+ | `shell.sidebar.item` | `.role-… span` | `:deep(span)` |
33
+ | `filter.input` / `input.base` | `.role-… .el-input__wrapper`(EP 视觉盒,边框在 box-shadow 上) | `:deep(.el-input__wrapper)` |
34
+ | `select.base` | `.role-… .el-select__wrapper` | `:deep(.el-select__wrapper)` |
35
+ | `shell.tabs.item` | 直接作用在文本节点(无需穿透) | 同左 |
36
+
37
+ **规则**:`host[]` 作用在组件根元素(加角色类即可);`inner[]` 作用在根元素内部——按上表目标写选择器。**全局文件里出现 `:deep()`,浏览器静默丢弃整条规则且无任何报错**——这是「只有主题色对、其余全不对」事故的根源(见实证坑 1)。
31
38
 
32
39
  ## 组件库前缀速查
33
40
 
@@ -56,39 +63,83 @@ description: Vue 3 + Element Plus 适配。提供深度选择器语法、组件
56
63
  | `shell.sidebar.item` / `.active` | `<el-menu-item :class="[...]">` | 直接作用在组件根元素 |
57
64
  | `shell.header.icon` | `<el-button :class="[...]">` | 同上 |
58
65
  | `button.primary` / `.secondary` / `.danger` | `<el-button :class="[...]">` | 同上 |
59
- | `card.base` | `<el-card :class="[...]">` | 内边距在 `.el-card__body`,`host` 作用在根、`inner` 用 `:deep(.el-card__body)` |
66
+ | `card.base` | `<el-card :class="[...]">` | 内边距在 `.el-card__body`:全局文件写后代规则 `.role-page-card > .el-card__body`;项目样式层对其 padding 带 `!important` 时同属性提权(实证坑 3) |
60
67
  | `table.wrapper` | `<el-table :class="[...]">` | 表格**根元素**挂 wrapper |
61
- | `table.headerCell` | **需走插槽**:`<template #header><th class="role-table-header-cell">` | Element Plus 的 `th` 不能直接加类 |
62
- | `table.cell` | `<el-table-column :class="[...]">` 或插槽内 `td` | 列组件加类 |
63
- | `table.cell.amount` | 同上 + `:deep(.cell)` 承接 `inner` | 右对齐靠 `text-align` |
68
+ | `table.headerCell` | `:header-cell-class-name="() => 'role-table-header-cell'"` | EP 的 `th` 不能直接加类;`<template #header><th class>` **无效**(加不进 EP 生成的 `th`) |
69
+ | `table.cell` | 列级 `class-name="role-table-cell"` 或 `:cell-class-name` 回调 | `<el-table-column :class>` **无效**(类落在列组件上,不落到 td) |
70
+ | `table.cell.amount` | `:cell-class-name` 回调按列派发 `.role-table-cell-amount` | 右对齐写在该角色类的 `.cell` 层(`text-align: right`) |
64
71
  | `tag.status` | `<el-tag :class="[...]">` | 直接作用 |
65
- | `input.base` / `filter.input` | `<el-input :class="[...]">` | `inner` 用 `:deep(.el-input__wrapper)` 或 `:deep(input)` |
66
- | `select.base` | `<el-select :class="[...]">` | `inner` 用 `:deep(.el-select__wrapper)` |
72
+ | `input.base` / `filter.input` | `<el-input :class="[...]">` | `inner` 用后代规则 `.role-… .el-input__wrapper`(scoped 内才用 `:deep()`) |
73
+ | `select.base` | `<el-select :class="[...]">` | `inner` 用后代规则 `.role-… .el-select__wrapper` |
67
74
  | `filter.dateRange` | `<el-date-picker :class="[...]">` | — |
68
75
  | `pagination.bar` | `<el-pagination :class="[...]">` | 直接作用 |
69
76
  | `shell.tabs.item` | 自绘或 `<el-tab-pane>` | 激活态另由 `item.active` 角色承载 |
70
77
 
71
- **Table 的特殊性**:`el-table` 的 `th` / `td` 由组件内部生成,**不能直接在 `<el-table>` 上加类来影响单元格**。表头必须用 `#header` 插槽自绘:
78
+ **Table 的特殊性**:`el-table` 的 `th` / `td` 由组件内部生成,**不能在 `<el-table>` 或 `<el-table-column>` 上直接加类来影响单元格**。正确通道是 EP 提供的三个回调/属性:
72
79
 
73
80
  ```vue
74
- <el-table :data="rows" class="role-table-wrapper">
75
- <template #header>
76
- <div class="role-table-header-cell">运单号</div>
77
- </template>
78
- <el-table-column prop="name" label="名称" class="role-table-cell" />
79
- <el-table-column prop="fee" label="运费" class="role-table-cell-amount" />
80
- <template #default="{ row }">
81
- <el-tag class="role-tag-status">{{ row.status }}</el-tag>
82
- </template>
81
+ <el-table
82
+ :data="rows"
83
+ class="role-table-wrapper"
84
+ :header-cell-class-name="() => 'role-table-header-cell'"
85
+ :cell-class-name="cellClassName"
86
+ >
87
+ <!-- 列级:能按列名静态指定的,直接用 class-name -->
88
+ <el-table-column type="selection" class-name="role-table-cell-selection" />
89
+ <el-table-column type="index" class-name="role-table-cell-index" />
90
+ <el-table-column prop="no" label="运单号" class-name="role-table-cell" />
91
+ <el-table-column prop="fee" label="运费" class-name="role-table-cell-amount" />
92
+ <el-table-column prop="ops" label="操作" class-name="role-table-cell-action" />
93
+
94
+ <!-- 单元格内部内容(状态标签)走 default 插槽 -->
95
+ <el-table-column prop="status" label="运单状态" class-name="role-table-cell-status">
96
+ <template #default="{ row }">
97
+ <el-tag class="role-tag-status">{{ row.status }}</el-tag>
98
+ </template>
99
+ </el-table-column>
83
100
  </el-table>
101
+
102
+ <script setup lang="ts">
103
+ // 需要按行/列动态判断的,用回调按列派发
104
+ const cellClassName = ({ row, column, columnIndex }: any) => {
105
+ if (column.property === 'fee') return 'role-table-cell-amount';
106
+ if (column.property === 'status') return 'role-table-cell-status';
107
+ return 'role-table-cell';
108
+ };
109
+ </script>
84
110
  ```
85
111
 
112
+ **三个无效写法(实测事故)**:`<template #header><th class="role-…">`(加不进 EP 生成的 th)、`<el-table-column :class>`(类不落到 td)、在 `<el-table>` 上加单元格类(只影响根元素)。
113
+
86
114
  ### 批量挂载的做法
87
115
 
88
116
  一个页面有 20+ 角色时**逐个手写 class 会漏**。做法:把契约的角色名转成类名清单,交给页面统一挂载,并在第 0 步自检里确认覆盖率 100%。
89
117
 
90
118
  **类名转换规则**:`table.headerCell` → `.role-table-header-cell`(点号与驼峰都转小写连字符)。
91
119
 
120
+ ## 结构等价转换(契约值 → EP DOM 结构)
121
+
122
+ 契约声明的是**设计意图值**;EP 的 DOM 结构有时要求把值**换一种 CSS 机制落到对应元素**——这是翻译的一部分,不是改契约。生成前逐条对照本表:
123
+
124
+ | 契约声明 | EP DOM 事实 | 生成规则 |
125
+ |---|---|---|
126
+ | `border: 1px solid X`(输入类控件) | EP 输入框边框是 `.el-input__wrapper` 的 `box-shadow: 0 0 0 1px #DCDFE6 inset` | 生成 `box-shadow: 0 0 0 1px X inset`(整条覆盖 EP 默认 box-shadow,勿只写 border-color) |
127
+ | 表头 `height: 48px` + `padding`(左右间距) | 表头双层:`th.el-table__cell > .cell` | **拆解**:th 的上下 padding 归零(高度才精确等于 48px),左右间距放内层 `.cell`(`padding: 0 16px`) |
128
+ | 单元格 `height: 55px` + `padding` | td 同为双层 | 同上拆解;density 变体只改 height 与竖向 padding |
129
+ | 分页激活页 `background` | EP 激活页 `.el-pager li.is-active` 默认只有字色 | 背景色落到 `.role-pagination-bar .el-pager li.is-active`(后代规则) |
130
+ | 侧栏激活项「左侧竖条」变体 | `.el-menu-item.is-active` 自带底色 | 竖条用 `::before` 伪元素(`position:absolute; left:-8px; width:3px`),菜单项保持 `position: relative` |
131
+ | 卡片 `padding: 0` | 内边距在 `.el-card__body`,项目样式层对其带 `!important` | 写 `.role-page-card > .el-card__body { padding: 0 !important; }`(实证坑 3 的唯一提权例) |
132
+ | 表格行 hover / 选中底色 | 行状态类由 EP 挂在 `tr` 上(`el-table__row-hover` 等) | 用 `.el-table` 状态类 + 角色类的后代规则实现(`when` 联动),不手写 JS 监听 |
133
+
134
+ **判断顺序**:先查本表 → 表里没有的结构差异,查「角色类挂对元素没」(上一节挂载表)→ 都不是,才回查契约值本身。
135
+
136
+ ## 实证坑(踩过的事故,生成时必须规避)
137
+
138
+ 1. **`:deep()` 在全局 CSS 文件中是非法语法**:浏览器会**静默丢弃整条规则**(不报错),导致 inner 层样式全部失效、只剩主题色生效。全局文件穿透一律用普通后代选择器(`.role-x .el-y__wrapper`)。
139
+ 2. **角色类不得覆盖宿主容器的布局模式**:若角色类挂在项目既有 flex/grid 容器上(如 `.vts-table-area` 是 flex column),CSS 里只写视觉属性(padding/border/颜色),**禁止写 `display`/`flex-direction`/`justify-content`**,否则内部子元素被横向挤压裁切。
140
+ 3. **Element Plus 对 `.el-card__body` 的 padding 带 `!important`**(项目 element-plus.scss):角色类要归零卡片内边距,必须同属性加 `!important` 提权,否则不生效。
141
+ 4. **表格行高精确控制**:`th/td` 的上下 padding 必须归零、左右间距放到内部 `.cell` 上(`padding: 0 16px`),否则 height 会被 padding 撑大(48px 变 57px 事故)。
142
+
92
143
  ## 提权通道(唯一允许处)
93
144
 
94
145
  角色类是独立命名空间(`role-` 前缀)+ 独立文件,**通常不需要提权**。仅两种情况需要:
@@ -36,7 +36,7 @@ src/styles/ui-layout.css 区域网格
36
36
 
37
37
  ---
38
38
 
39
- ## 执行流程(六步)
39
+ ## 执行流程(七步)
40
40
 
41
41
  ### 第 0 步 · 落地自检(先跑,断在哪一目了然)
42
42
 
@@ -44,18 +44,22 @@ src/styles/ui-layout.css 区域网格
44
44
  node <技能>/scripts/check-landing.mjs --project <项目根>
45
45
  ```
46
46
 
47
- **六项检查,逐项给修复动作,exit 1 表示链路有断点:**
47
+ **八项检查,逐项给修复动作,exit 1 表示链路有断点:**
48
48
 
49
49
  | # | 检查项 | 断点含义 |
50
50
  |---|---|---|
51
51
  | 1 | `contract-found` | `.design/visual-contract-*.json` 不存在 → 设计侧没落盘 |
52
52
  | 2 | `contract-valid` | 契约字段缺失(`meta.scene` / `tokens.color` / `roles` / `layout.areas`) |
53
- | 3 | `css-generated` | 三份 CSS 未生成 → 第 3/4/5 步没执行 |
54
- | 4 | `css-imported` | **生成了但没被 import** → 浏览器根本不加载 |
55
- | 5 | `roles-mounted` | **CSS 有规则但模板没挂类** → 死样式,视觉不变 |
56
- | 6 | `layout-mounted` | 模板没出现 `.ui-page` → 骨架仍是项目原样 |
53
+ | 3 | `contract-structure` | 角色名不在角色库 / `regions` 与 `areas` 不一致 / 绑定的区域名不存在 → 设计侧结构违规,**回设计侧修** |
54
+ | 4 | `css-generated` | 三份 CSS 未生成 → 第 3/4/5 步没执行 |
55
+ | 5 | `css-imported` | **生成了但没被 import** → 浏览器根本不加载 |
56
+ | 6 | `roles-mounted` | **CSS 有规则但模板没挂类** → 死样式,视觉不变 |
57
+ | 7 | `layout-mounted` | 模板没出现 `.ui-page` → 骨架仍是项目原样 |
58
+ | 8 | `css-rules` | 模板挂了类但 CSS 里没有对应规则 → 生成不完整或被手改 |
57
59
 
58
- **六项全过是交付前置条件。** 不过就修断点,**不要直接目视猜问题在哪**。
60
+ **八项全过是交付前置条件。** 不过就修断点,**不要直接目视猜问题在哪**。
61
+
62
+ > **⚠️ 导入顺序硬坑(实证)**:三份 CSS 必须作为 **TS import 在组件库全量 CSS 之后**引入(如 `main.ts` 里 `import 'element-plus/dist/index.css'` 之后)。若 `@import` 进项目 `index.scss`,而组件库 CSS 在 `main.ts` 中晚于 `index.scss` 加载(独立 `<style>` 标签),则契约层会被组件库同特异性规则**整层盖住**——表现为「只有主题色对、其余全不对」。`adapter.output.entryImport` 指定正确入口。
59
63
 
60
64
  ### 第 1 步 · 读契约(只读一次)
61
65
 
@@ -81,6 +85,16 @@ node <技能>/scripts/check-landing.mjs --project <项目根>
81
85
 
82
86
  **adapter 不定义设计意图、不定义风格、不改写契约值。**
83
87
 
88
+ ### 第 3/4/5 步 · 生成三份 CSS(机器执行,禁止手抄)
89
+
90
+ ```bash
91
+ node <技能>/scripts/generate-css.mjs --project <项目根>
92
+ ```
93
+
94
+ **`generate-css.mjs` 是第 3/4/5 步的可执行实现**:读契约 `tokens/roles/layout` + `adapters/<adapter>.json`(机器可读映射:`innerTargets`/`stateChannels`/`extraRules`/`variableBridges`/`layoutBridge`),确定性生成三份 CSS。**同一输入必得同一输出**——手抄会漏值、引错变量名、状态写不全(这正是历次「只有主题色对」事故的根源)。
95
+
96
+ 下面第 3/4/5 步的「翻译规则表」是生成器的**行为规格**与人工核对依据,不再让 AI 逐条手写:
97
+
84
98
  ### 第 3 步 · 写令牌 → `src/styles/ui-tokens.css`
85
99
 
86
100
  契约 `tokens` 逐条转 CSS 变量:
@@ -105,9 +119,9 @@ node <技能>/scripts/check-landing.mjs --project <项目根>
105
119
  契约 `roles` 逐条转类规则。**这是纯粹的翻译,每个角色一段:**
106
120
 
107
121
  ```css
108
- /* 角色:table.headerCell */
122
+ /* 角色:table.headerCell —— ui-roles.css 是全局文件:内层穿透用普通后代选择器,禁 :deep() */
109
123
  .role-table-header-cell { background-color: #FAFAFA; height: 48px; padding: 16px; border-bottom: 1px solid var(--color-border-base); }
110
- .role-table-header-cell :deep(span) { font-size: var(--font-size-md); font-weight: 500; color: var(--color-text-primary); text-align: left; white-space: nowrap; }
124
+ .role-table-header-cell .cell { font-size: var(--font-size-md); font-weight: 500; color: var(--color-text-primary); text-align: left; white-space: nowrap; }
111
125
  ```
112
126
 
113
127
  **翻译规则表**:
@@ -115,13 +129,15 @@ node <技能>/scripts/check-landing.mjs --project <项目根>
115
129
  | 契约结构 | 生成 |
116
130
  |---|---|
117
131
  | `roles.<名>.host[]` | `.role-<名转连字符> { … }` |
118
- | `roles.<名>.inner[]` | 同类名 + adapter 的内层穿透语法(`:deep()` / `>>>` / 属性选择器) |
132
+ | `roles.<名>.inner[]` | 同类名 + adapter 给出的内层目标选择器(**全局文件用普通后代选择器**;`:deep()` 只允许出现在 SFC `<style scoped>`——通道写错整条规则被浏览器**静默丢弃、无任何报错**) |
119
133
  | `roles.<名>.states.hover.host[]` | `.role-<名>:hover { … }` |
120
134
  | `roles.<名>.states.disabled.inner[]` | `.role-<名>:disabled { … }`(或 adapter 的 disabled 通道) |
121
135
  | 声明值 `{令牌}` | 替换为 `var(--令牌)` |
122
136
 
123
137
  **角色名转换**:`table.headerCell` → 类名 `.role-table-header-cell`(点号转连字符 + `role-` 前缀,避免与组件库类名冲突)。
124
138
 
139
+ **契约值 → 组件库 DOM 的结构等价转换**:组件库真实 DOM 与契约属性并非一一对应(如 Element Plus 输入框边框实际是 `box-shadow: … inset`、表头是 `th > .cell` 双层结构、卡片内边距由 `.el-card__body` 承载并带 `!important`)。这类转换**以 adapter 的「结构等价转换」表为准,禁止现场临时发明**;转换只改「属性落在哪个 DOM 节点、用什么属性表达」,**不改契约值本身**——值仍然来自契约,只是表达载体等价替换。
140
+
125
141
  **AI 在这一步不做的事**:
126
142
 
127
143
  | 不做 | 为什么 |
@@ -211,11 +227,13 @@ node <技能>/scripts/check-landing.mjs --project <项目根>
211
227
 
212
228
  ### 第 6 步 · 挂载 + 覆盖验证
213
229
 
214
- 1. 在样式入口引入三份文件(一次性,入口位置按项目现状)
230
+ 1. 在样式入口引入三份文件(**入口位置按 `adapter.output.entryImport`——通常是 main.ts 在组件库全量 CSS 之后**)
215
231
  2. 页面根节点挂 `.ui-page`,各区域挂 `.ui-region-<名>`
216
232
  3. 按 adapter 的「角色类挂载方式」给组件挂 `.role-*`(**表格走插槽,不是在 `<el-table>` 上加类**)
217
- 4. **重跑第 0 步的落地自检,六项全过才算挂载完成**
218
- 5. 目视对照:打开页面与契约 `meta.source.mockup` 指向的效果图并排看
233
+ 4. **按契约 `structure` 重建页面骨架**——区块数量/顺序、列数与列宽档位、控件类型、图标 path、折叠/吸附结构,**照骨架对齐**。
234
+ **⚠️ 形态/内容分离(红线)**:`structure` 里标了 `sampleOnly` 的业务内容(列名文字、筛选项 label、数据行、菜单文字、状态文案)**一律不烧进项目**——项目侧保持自身业务字段(列名/prop/API/mock/权限/字典),只把「形态」对齐骨架。设计稿的演示占位不是目标项目的业务事实;覆盖业务逻辑 = 交付失败。
235
+ 5. **重跑第 0 步的落地自检,八项全过才算挂载完成**
236
+ 6. 进入第 7 步视觉验收(computed style 逐点比对契约 + visualSanity 通用断言,目视截图只作最后留档)
219
237
 
220
238
  | 现象 | 根因 | 修正动作 |
221
239
  |---|---|---|
@@ -230,13 +248,43 @@ node <技能>/scripts/check-landing.mjs --project <项目根>
230
248
  | 布局位置不对 | `areas` 排布或 `regions` 取值不对 | 改 `layout` → 重新生成 |
231
249
  | 某角色完全没生效 | 该角色没写进契约 | **回设计侧补抽**,不是项目侧猜 |
232
250
 
251
+ ### 第 7 步 · 视觉验收(computed style 逐点比对契约)
252
+
253
+ 目视对照只能发现「明显不像」,发现不了「差 2px、差一档灰」。**验收的期望源就是契约 JSON 本身**——不另建期望值文件、不另建长期采集脚本。
254
+
255
+ ```bash
256
+ node <技能>/scripts/verify-render.mjs --project <项目根> --url http://localhost:<dev端口>
257
+ ```
258
+
259
+ **`verify-render.mjs` 是本步的可执行实现**:Playwright 登录 → 导航目标页 → 对契约每条声明读 `getComputedStyle` 逐点比对(颜色归一、几何容差 ≤2px、`auto`/`unitless line-height`/`repeat()` 等计算值假差异自动放行)→ 输出 `[FAIL] 角色·选择器·属性 期望/实际/命中元素` 差异报告 + 全页截图。差异清零即视觉验收通过。
260
+
261
+ **第二遍:visualSanity 通用断言(契约值之外的 UX 合理性)**
262
+
263
+ 契约比对只保证「值抄对了」,不保证「值组合起来合理」——表头换行、文字省略号、操作列截断、元素被隐藏都是**组合性缺陷**,逐点比对全绿也可能发生。断言规则沉淀在 `adapter.visualSanity`(通用编排,换场景/页型自动生效),至少覆盖:
264
+
265
+ | 断言 | 判据 | 典型根因 |
266
+ |---|---|---|
267
+ | no-wrap | 表头/标签/按钮 `.cell` 的 `scrollWidth ≤ clientWidth+1` 且 `white-space:nowrap` | 宽度档位不够 / padding 吃掉空间 |
268
+ | no-ellipsis | 可见文本元素计算值 `text-overflow` 未实际截断(scrollWidth 不超) | 列宽写死 / .cell overflow:hidden |
269
+ | full-visibility | 契约 structure 声明的每个元素 `display≠none && rect.width>0`(折叠钮、分隔线、操作列按钮组) | 被组件库/UnoCSS 隐藏、图标未注册 |
270
+ | padding-sanity | 内容区左右 padding ≥ 契约 region 值(操作列留边) | padding 被归零过头 |
271
+ | row-height-sanity | 数据行实际高度 = 密度档值(±2px) | padding/line-height 撑高 |
272
+
273
+ **修复定向不变**:visualSanity 失败 → 修 adapter `extraRules`(提权/结构等价转换)或契约值,不手改生成物。
274
+
275
+ 1. 启动项目 dev server,跑 `verify-render.mjs`(脚本内置真实账号登录链路,不留特殊开关)
276
+ 2. 逐条消化差异,定位顺序(由外向内):**挂载遗漏 → 穿透/选择器通道错 → 提权被组件库盖住(`!important` 对决比特异性)→ 契约值本身错**——前三者修落地侧(第 4/5/6 步或 adapter 的 `extraRules`),最后一种回设计侧改契约后重新生成
277
+ 3. 全部命中(契约差异 0 + visualSanity 全过)后截图,与 `meta.source.mockup` 指向的设计稿并排留档
278
+
279
+ **永不直接改生成出来的 CSS 来「凑」比对结果**——比对失败的修复动作永远落回第 4/5/6 步或契约。
280
+
233
281
  ### 修正的唯一定向(不可破)
234
282
 
235
283
  **只改契约或改 adapter,永远不直接改生成出来的 CSS。**
236
284
 
237
285
  直接改 CSS 会在下次重新生成时被覆盖,且这份覆盖不会留下痕迹——同类问题会在下一轮以另一种形态复发。改契约的成本远低于改代码,因为契约是结构化的、可定位的、可重放的。
238
286
 
239
- **不做**:不建期望值文件、不建采集脚本、不跑回归、不写逐点 diff 报告。
287
+ **不做**:不建独立的期望值文件(期望源就是契约 JSON)、不把验收脚本沉淀进项目长期维护、不跑业务回归、不写长期维护的逐点 diff 报告(第 7 步的一次性比对除外)。
240
288
 
241
289
  ---
242
290
 
@@ -7,13 +7,14 @@
7
7
  * node check-landing.mjs --project <项目根目录>
8
8
  * node check-landing.mjs --project . --json
9
9
  *
10
- * 退出码: 0 = 六项全过 / 1 = 有 FAIL 项
10
+ * 退出码: 0 = 八项全过 / 1 = 有 FAIL 项
11
11
  *
12
12
  * 设计原则: 纯读取 + 确定性判断。不猜测、不修改、不联网。
13
13
  */
14
14
 
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
17
18
 
18
19
  const IGNORE_DIRS = new Set([
19
20
  "node_modules", "dist", ".git", ".vite", "coverage",
@@ -260,6 +261,122 @@ function checkMounting(projectRoot, roleNames) {
260
261
  };
261
262
  }
262
263
 
264
+ // ────────────────────────────── 结构校验(契约 §7)
265
+
266
+ const LIBRARY_DIR = path.resolve(
267
+ path.dirname(fileURLToPath(import.meta.url)),
268
+ "../../project-ui-design/references/library"
269
+ );
270
+
271
+ /** 加载角色库名集合:<场景>.json + components.json;目录不可达返回 null(降级 WARN) */
272
+ function loadRoleLibrary(scene) {
273
+ const merged = new Set();
274
+ for (const f of [`${scene}.json`, "components.json"]) {
275
+ const p = path.join(LIBRARY_DIR, f);
276
+ if (!exists(p)) continue;
277
+ try {
278
+ const lib = JSON.parse(readSafe(p));
279
+ for (const k of Object.keys(lib.roles || {})) merged.add(k);
280
+ } catch {
281
+ /* 单个角色库文件损坏时跳过,由调用方按降级处理 */
282
+ }
283
+ }
284
+ return merged.size > 0 ? merged : null;
285
+ }
286
+
287
+ /** 契约 §7 结构四条中可机判的三条:角色名在库 / regions↔areas 一致 / 绑定区域存在 */
288
+ function checkContractStructure(contract, roleNames, library) {
289
+ const problems = [];
290
+
291
+ if (library) {
292
+ const unknown = roleNames.filter((n) => !library.has(n));
293
+ if (unknown.length > 0) {
294
+ problems.push(`角色不在角色库: ${unknown.join(", ")}`);
295
+ }
296
+ }
297
+
298
+ const layout = contract.layout || {};
299
+ const areaNames = (Array.isArray(layout.areas) ? layout.areas : [])
300
+ .map((a) => a && a.name)
301
+ .filter(Boolean);
302
+ const areasSet = new Set(areaNames);
303
+ const regionKeys = Object.keys(layout.regions || {});
304
+ const onlyRegions = regionKeys.filter((k) => !areasSet.has(k));
305
+ const onlyAreas = areaNames.filter((n) => !regionKeys.includes(n));
306
+ if (onlyRegions.length > 0) {
307
+ problems.push(`layout.regions 有而 layout.areas 没有: ${onlyRegions.join(", ")}`);
308
+ }
309
+ if (onlyAreas.length > 0) {
310
+ problems.push(`layout.areas 有而 layout.regions 没有: ${onlyAreas.join(", ")}`);
311
+ }
312
+
313
+ const pages = contract.pages || {};
314
+ for (const [pt, page] of Object.entries(pages)) {
315
+ const p = page || {};
316
+ for (const b of Array.isArray(p.regions) ? p.regions : []) {
317
+ if (b && b.region && !areasSet.has(b.region)) {
318
+ problems.push(`pages.${pt}.regions 绑定了不存在的区域: ${b.region}`);
319
+ }
320
+ }
321
+ for (const nd of Array.isArray(p.nodes) ? p.nodes : []) {
322
+ if (nd && nd.in && !areasSet.has(nd.in)) {
323
+ problems.push(`pages.${pt}.nodes 绑定了不存在的区域: ${nd.in}`);
324
+ }
325
+ }
326
+ }
327
+ return { problems };
328
+ }
329
+
330
+ // ────────────────────────────── CSS 规则完整性
331
+
332
+ /** 逐条核对:ui-roles 含每个角色类、ui-layout 含每个区域类、ui-tokens 含每个令牌变量 */
333
+ function checkCssRules(cssPathOf, contract, roleNames) {
334
+ const missing = [];
335
+
336
+ const rolesCss = readSafe(cssPathOf["ui-roles.css"] || "");
337
+ if (rolesCss) {
338
+ for (const rn of roleNames) {
339
+ const cls = "." + expectedClassName(rn);
340
+ if (!rolesCss.includes(cls)) missing.push(`ui-roles.css 缺 ${cls}`);
341
+ }
342
+ } else {
343
+ missing.push("ui-roles.css 无法读取");
344
+ }
345
+
346
+ const layoutCss = readSafe(cssPathOf["ui-layout.css"] || "");
347
+ if (layoutCss) {
348
+ const areas = Array.isArray(contract.layout && contract.layout.areas)
349
+ ? contract.layout.areas
350
+ : [];
351
+ for (const a of areas) {
352
+ if (!a || !a.name) continue;
353
+ const cls = `.ui-region-${a.name}`;
354
+ if (!layoutCss.includes(cls)) missing.push(`ui-layout.css 缺 ${cls}`);
355
+ }
356
+ } else {
357
+ missing.push("ui-layout.css 无法读取");
358
+ }
359
+
360
+ const tokensCss = readSafe(cssPathOf["ui-tokens.css"] || "");
361
+ if (tokensCss) {
362
+ const tokens = contract.tokens || {};
363
+ for (const [family, names] of Object.entries(tokens)) {
364
+ if (!names || typeof names !== "object") continue;
365
+ for (const n of Object.keys(names)) {
366
+ const v = `--${family}-${String(n).replace(/\./g, "-")}`;
367
+ const alt = `--${family}-${String(n).replace(/\./g, "_")}`;
368
+ if (!tokensCss.includes(v) && !tokensCss.includes(alt)) {
369
+ missing.push(`ui-tokens.css 缺 ${v}`);
370
+ }
371
+ }
372
+ }
373
+ } else {
374
+ missing.push("ui-tokens.css 无法读取");
375
+ }
376
+
377
+ return missing;
378
+ }
379
+
263
380
  // ────────────────────────────── 主流程
264
381
 
265
382
  function run(projectRoot) {
@@ -306,7 +423,31 @@ function run(projectRoot) {
306
423
  return { checks, ok: false, projectRoot, contractInfo: parsed.info };
307
424
  }
308
425
 
309
- // 3. 三份 CSS 生成
426
+ // 3. 契约结构(角色名在库 / regions↔areas 一致 / 页面绑定不悬空)
427
+ const library = loadRoleLibrary(parsed.info.scene);
428
+ const struct = checkContractStructure(contract, parsed.roleNames, library);
429
+ const structStatus =
430
+ struct.problems.length > 0 ? "FAIL" : library ? "PASS" : "WARN";
431
+ add(
432
+ "contract-structure",
433
+ "契约结构符合角色库与布局约束",
434
+ structStatus,
435
+ structStatus === "PASS"
436
+ ? `${parsed.roleNames.length} 个角色全部在角色库;regions 与 areas 一致(${parsed.info.areaCount} 个区域);页面绑定无悬空引用`
437
+ : structStatus === "WARN"
438
+ ? "角色库目录不可达,跳过「角色名在库」校验(regions↔areas 与页面绑定校验已通过)"
439
+ : struct.problems.join(";"),
440
+ structStatus === "FAIL"
441
+ ? "设计侧结构违规 —— 回设计侧修正契约(角色名须在库、layout.regions 与 layout.areas 一致、绑定的区域名必须存在),重新出契约。"
442
+ : structStatus === "WARN"
443
+ ? "确认技能包完整(project-ui-design/references/library 存在)后重跑。"
444
+ : null
445
+ );
446
+ if (structStatus === "FAIL") {
447
+ return { checks, ok: false, projectRoot, contractInfo: parsed.info };
448
+ }
449
+
450
+ // 4. 三份 CSS 生成
310
451
  const css = findGeneratedCss(projectRoot);
311
452
  const missingCss = CSS_FILES.filter((n) => !css.pathOf[n]);
312
453
  add(
@@ -321,7 +462,7 @@ function run(projectRoot) {
321
462
  : null
322
463
  );
323
464
  if (missingCss.length === 0) {
324
- // 4. CSS 被引入
465
+ // 5. CSS 被引入
325
466
  const unimported = [];
326
467
  const importDetail = [];
327
468
  for (const n of CSS_FILES) {
@@ -340,7 +481,7 @@ function run(projectRoot) {
340
481
  );
341
482
  }
342
483
 
343
- // 5. 角色类挂载
484
+ // 6. 角色类挂载
344
485
  const mount = checkMounting(projectRoot, parsed.roleNames);
345
486
  const mountRatio = parsed.roleNames.length
346
487
  ? mount.mounted.length / parsed.roleNames.length
@@ -358,7 +499,7 @@ function run(projectRoot) {
358
499
  : null
359
500
  );
360
501
 
361
- // 6. 布局类挂载
502
+ // 7. 布局类挂载
362
503
  add(
363
504
  "layout-mounted",
364
505
  "布局类挂到容器上",
@@ -369,6 +510,22 @@ function run(projectRoot) {
369
510
  mount.hasLayout ? null : "布局组件需加 .ui-page 与 .ui-region-* 类,见 adapter 的「布局占位容器」。"
370
511
  );
371
512
 
513
+ // 8. CSS 规则完整性(模板挂了类但 CSS 没规则 = 生成不完整或被手改)
514
+ if (missingCss.length === 0) {
515
+ const missingRules = checkCssRules(css.pathOf, contract, parsed.roleNames);
516
+ add(
517
+ "css-rules",
518
+ "CSS 规则覆盖契约全部声明",
519
+ missingRules.length === 0 ? "PASS" : "FAIL",
520
+ missingRules.length === 0
521
+ ? `ui-tokens / ui-roles / ui-layout 已覆盖契约全部令牌、${parsed.info.roleCount} 个角色与 ${parsed.info.areaCount} 个区域`
522
+ : `缺 ${missingRules.length} 条: ${missingRules.slice(0, 8).join(" | ")}${missingRules.length > 8 ? " …" : ""}`,
523
+ missingRules.length
524
+ ? "模板挂了类但 CSS 里没有对应规则 —— 生成不完整或生成后被手工改动。回第 3/4/5 步重新生成,禁止手补 CSS。"
525
+ : null
526
+ );
527
+ }
528
+
372
529
  const ok = checks.every((c) => c.status === "PASS");
373
530
  return { checks, ok, projectRoot, contractInfo: parsed.info, mount };
374
531
  }
@@ -392,7 +549,7 @@ function printHuman(result) {
392
549
  L.push("-".repeat(64));
393
550
  const firstFail = result.checks.find((c) => c.status === "FAIL");
394
551
  if (result.ok) {
395
- L.push("结论: 六项全过 —— 契约、样式、挂载三个闭环均已接通。");
552
+ L.push("结论: 八项全过 —— 契约、结构、样式、挂载闭环均已接通。");
396
553
  } else if (firstFail) {
397
554
  L.push(`结论: 断在「${firstFail.title}」。先修这一项,重跑本脚本看下一项。`);
398
555
  L.push("");