@qilitt-mickey/vue3-temp-skill 1.1.63 → 1.1.64
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 +2 -2
- package/mapping/scenes/admin/content.json +23 -3
- package/mapping/scenes/admin/layout.json +20 -7
- package/package.json +2 -1
- package/references/steps/step5-gates.md +13 -1
- package/references/steps/step6-verify.md +2 -0
- package/scripts/check.mjs +23 -0
- package/scripts/design-audit.mjs +878 -0
package/SKILL.md
CHANGED
|
@@ -32,7 +32,7 @@ tags: [vue3, typescript, element-plus, pinia, vite, unocss, crud, demo, code-qua
|
|
|
32
32
|
|
|
33
33
|
1. **项目探查**:读取目标项目的 `package.json`、路由、页面、`Re*` 组件、Hooks、API 和样式;以源码事实为准。
|
|
34
34
|
2. **能力路由**:运行 `scripts/match.py "需求" --json`,**仅**按输出中的 `load_files` 阅读 reference;禁止自行展开全部 `references/`。无 Python 时按下方能力组表手动匹配。
|
|
35
|
-
3. **质量闸门**:代码完成后读取 `references/code-quality.md
|
|
35
|
+
3. **质量闸门**:代码完成后读取 `references/code-quality.md`,执行清单和项目命令。设计变更集落地后追加**设计层闭环审计**:`node scripts/design-audit.mjs --project <落地项目> --container-width <内容区实际宽>`(ERROR 必须清零;无落地项目时自动跳过)。
|
|
36
36
|
|
|
37
37
|
## 能力组路由
|
|
38
38
|
|
|
@@ -51,7 +51,7 @@ tags: [vue3, typescript, element-plus, pinia, vite, unocss, crud, demo, code-qua
|
|
|
51
51
|
| `files-and-editors` | 附件、预览、下载、富文本、二维码 | `file-management.md`、`download-export.md`、`rich-text.md`、`qrcode-barcode.md` |
|
|
52
52
|
| `realtime-and-mobile` | WebSocket、移动端、微信、验证码、动效 | 对应专项 reference |
|
|
53
53
|
| `data-compare` | 编辑页变更对比和汇总 | `data-compare.md` |
|
|
54
|
-
| `design-apply` | 设计变更集落地:语义 ID 三级匹配、结构/形态/两层样式转换、锚点与结构施工、覆写提权、渲染态验收、合规闸门。**主入口 `design-apply.md`(红线+契约+步骤索引),各步骤明细见 `references/steps/`——做到哪一步读哪一份** | `design-apply.md`、`steps/`、`mapping/`、`parser
|
|
54
|
+
| `design-apply` | 设计变更集落地:语义 ID 三级匹配、结构/形态/两层样式转换、锚点与结构施工、覆写提权、渲染态验收、合规闸门。**主入口 `design-apply.md`(红线+契约+步骤索引),各步骤明细见 `references/steps/`——做到哪一步读哪一份** | `design-apply.md`、`steps/`、`mapping/`、`parser/`、`scripts/design-audit.mjs` |
|
|
55
55
|
|
|
56
56
|
## 工作流
|
|
57
57
|
|
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
"data_channels": {
|
|
153
153
|
"slot_width": "等宽槽宽度通道(动态优先):**槽宽默认由组件按按钮文案动态自适应**(ReTableOperate 的 --re-op-slot-w 在 slotMinWidth 省略时走 resolveOperateSlotWidthPx 自动推算),变更集**不声明 slot_width、业务页不传 slot-min-width** ——设计只声明 divider 几何(width/height/margin_x),槽宽随内容走、divider_margin_x 对称由槽内居中兑现(若声明 60px 钉死槽宽则违反动态优先;若槽内用 flex-start,短文案按钮的残白会全堆到分割线前侧 → 线前 40px/线后 8px 视觉不等距)。仅当设计效果图确实要求**统一槽宽**(如所有操作列等宽对齐网格)时例外:变更集显式声明 slot_width → 施工落业务页 prop(结构通道),禁 CSS 仿冒;无此明确设计意图 = 保持动态。等宽槽本体是对齐机制(槽边界保证行间第 N 项对齐),机制本身禁 CSS 收口 width:auto"
|
|
154
154
|
},
|
|
155
|
-
"$note": "操作列分割线为**条件通道**:出厂组件当前**没有**分割线元素——声明 divider_color / divider_gap 时,必须先按 structure_ops 在 ReTableOperate 模板按钮间 add_node `<span class=\"re-table-operate__divider\">`(中性元素,几何与颜色全走令牌:width/height/gap/color 分别挂 --re-op-divider-width/height/gap/color,出厂缺省 0 不可见),并同步给 resolveOperateColumnWidth 公式补分割线占位((limit-1)×(width+2×gap)),否则最后按钮被裁——元素与公式缺一即申报补通道失败。列宽由组件公式推算,业务页禁止写死 width
|
|
155
|
+
"$note": "操作列分割线为**条件通道**:出厂组件当前**没有**分割线元素——声明 divider_color / divider_gap 时,必须先按 structure_ops 在 ReTableOperate 模板按钮间 add_node `<span class=\"re-table-operate__divider\">`(中性元素,几何与颜色全走令牌:width/height/gap/color 分别挂 --re-op-divider-width/height/gap/color,出厂缺省 0 不可见),并同步给 resolveOperateColumnWidth 公式补分割线占位((limit-1)×(width+2×gap)),否则最后按钮被裁——元素与公式缺一即申报补通道失败。列宽由组件公式推算,业务页禁止写死 width;其余业务列(含选择列、序号列)逐列声明 width 或 min_width,见 admin.content.table.column_width(§11.132)。",
|
|
156
156
|
"custom_style_map": {
|
|
157
157
|
"align": "text-align",
|
|
158
158
|
"min_width": "min-width",
|
|
@@ -279,7 +279,27 @@
|
|
|
279
279
|
{ "kind": "class", "layer": "host", "target": ".vts-table-area .el-table__cell.col-index" },
|
|
280
280
|
{ "kind": "class", "layer": "inner", "target": ".vts-table-area .el-table__cell.col-index > .cell" }
|
|
281
281
|
],
|
|
282
|
-
"$note": "
|
|
282
|
+
"$note": "序号列宽度由设计侧给出意图(§11.131):固定 → structure_ops 在列定义绑定 class-name=col-index 并设 width prop;自适应 → 设 min-width prop。两种都必须显式声明,禁 CSS 收口 width(对表格列无效)。不声明 = 该列被相邻固定宽列挤压。"
|
|
283
|
+
},
|
|
284
|
+
"admin.content.table.column_width": {
|
|
285
|
+
"type": "element",
|
|
286
|
+
"selector": ".vts-table-area .el-table__cell",
|
|
287
|
+
"file": "业务 views(el-table-column 的 width / min-width prop;禁止写 CSS)",
|
|
288
|
+
"style_scope": ".vts-page-card",
|
|
289
|
+
"data_channels": {
|
|
290
|
+
"column_width": "固定宽通道:逐列映射到 el-table-column 的 width prop(该列不参与剩余空间分配)",
|
|
291
|
+
"min_width": "伸缩下限通道:逐列映射到 min-width prop(按比例吃剩余空间,容器不足时保住下限并触发横向滚动)",
|
|
292
|
+
"budget": "容器预算通道:Σ(width) + Σ(min-width) 与容器可用宽的比对结论(设计侧先算,项目侧据此决定是否启用横向滚动)"
|
|
293
|
+
},
|
|
294
|
+
"anchors": [
|
|
295
|
+
{ "kind": "class", "layer": "host", "target": ".vts-table-area .el-table__cell" }
|
|
296
|
+
],
|
|
297
|
+
"preconditions": [
|
|
298
|
+
"列宽必须落列定义(width / min-width prop),落 CSS 无效 —— 表格布局由列定义驱动,样式层写 width 对该列不产生效果。",
|
|
299
|
+
"每一列都必须给出宽度意图:既无 width 又无 min-width 的列,表格布局只能按出厂默认下限兜底,并被相邻固定宽列挤压。",
|
|
300
|
+
"伸缩列下限不得低于「表头文案宽 + 左右内边距」,否则表头文案会被压到换行或省略号截断。"
|
|
301
|
+
],
|
|
302
|
+
"$note": "列宽契约(list-page §11.132):逐列声明 width(固定)或 min_width(伸缩),二者必居其一;设计侧同时给出容器预算结论(Σ 列宽 vs 容器可用宽),预算不足时以横向滚动为唯一出口,禁止留给项目侧隐形压缩。"
|
|
283
303
|
},
|
|
284
304
|
"admin.content.table.cell_status": {
|
|
285
305
|
"type": "element",
|
|
@@ -387,7 +407,7 @@
|
|
|
387
407
|
],
|
|
388
408
|
"preconditions": [
|
|
389
409
|
"选择列由 el-table-column type=\"selection\" 渲染,Element Plus 自动在表头与单元格上加 el-table-column--selection 类——列未声明 type=selection 时本条目永不命中(项目源码实证:views/table/base/index.vue 已用 <el-table-column type=\"selection\" reserve-selection />)。",
|
|
390
|
-
"
|
|
410
|
+
"列宽经 admin.content.table.column_width 逐列声明(§11.132,width 或 min_width 必居其一);本条目只承载对齐与勾选态配色。"
|
|
391
411
|
],
|
|
392
412
|
"priority": {
|
|
393
413
|
"strategy": "scope_qualified",
|
|
@@ -106,7 +106,8 @@
|
|
|
106
106
|
],
|
|
107
107
|
"preconditions": [
|
|
108
108
|
"出厂菜单项盒模型是「无横向 margin(margin: 4px 0)+ 胶囊用 inset 内缩」;指示条几何必须以这个宿主盒为基准计算偏移。照搬外部效果图上「依赖菜单项横向 margin」的负偏移量会落进容器外并被 overflow 裁掉(这是指示条「映射了却没效果」的根因:值搬了、让该值成立的宿主盒模型没搬)。",
|
|
109
|
-
"--vts-theme-menu-active-before 出厂为透明且全项目无消费者;声明 indicator_* 形态必须成对落地「关胶囊令牌 + 新建消费者」,只改色值不建消费者 = 未施工。"
|
|
109
|
+
"--vts-theme-menu-active-before 出厂为透明且全项目无消费者;声明 indicator_* 形态必须成对落地「关胶囊令牌 + 新建消费者」,只改色值不建消费者 = 未施工。",
|
|
110
|
+
"激活态作用层级(active_scope,默认 leaf):胶囊与指示条**只作用于叶子菜单项**(.el-menu-item.is-active)。可展开分组的标题(.el-sub-menu__title)在「其下有激活子项」时会被 EP 打上 is-active,**不得**继承胶囊与指示条——否则分组标题同时长出胶囊、竖条并与右侧展开箭头叠压。设计层写激活态时必须把选择器锚定叶子项,禁止把 .el-sub-menu.is-active > .el-sub-menu__title 与 .el-menu-item.is-active 并列写成同一组激活声明。"
|
|
110
111
|
],
|
|
111
112
|
"priority": {
|
|
112
113
|
"strategy": "deep_qualified",
|
|
@@ -223,11 +224,18 @@
|
|
|
223
224
|
"style_scope": ".navbar",
|
|
224
225
|
"forms": {
|
|
225
226
|
"icon_only": "出厂现状:纯图标触发器(.language-trigger > .language-icon),下拉菜单选语言",
|
|
226
|
-
"inline_switch": "
|
|
227
|
+
"inline_switch": "并排切换形态:中 / EN 平铺在触发器内,点击即切换。**落地必须成套三件事**——① 显并排文字(.language-text 由隐藏改可见);② 收口图标(suppress icon_only);③ **收口下拉浮层**(suppress dropdown_popper:出厂 ElDropdown 的 popper 必须不再弹出,否则点触发器会从下方展开 English/中文 浮层遮挡页签栏)。缺③ = 形态未落地(只加了文字、交互仍是下拉)"
|
|
227
228
|
},
|
|
228
229
|
"suppress_rules": {
|
|
229
|
-
"inline_switch": ["icon_only"]
|
|
230
|
+
"inline_switch": ["icon_only", "dropdown_popper"]
|
|
230
231
|
},
|
|
232
|
+
"structure_ops_required": {
|
|
233
|
+
"inline_switch": ["收口 ElDropdown 触发(改 trigger 为不可弹出 / 移除 dropdown 插槽消费 / 用保形 v-if 切换为直选按钮任一),使点击不再产生浮层"]
|
|
234
|
+
},
|
|
235
|
+
"preconditions": [
|
|
236
|
+
"语言切换的形态由 form 决定,且 form 是「加法 + 减法」成对语义:inline_switch 不仅要新增并排文字,还必须收口出厂的下拉浮层与图标形态。只加不减时,浮层仍会弹出(触发器与浮层二者并存),视觉上从触发器下方展开的浮层会压住页签栏。",
|
|
237
|
+
"浮层属 teleport 到 body 的 popper,样式层无法收口(.language-trigger 上的规则管不到 popper);必须从结构层收口 ElDropdown 的触发行为。"
|
|
238
|
+
],
|
|
231
239
|
"css_vars": {
|
|
232
240
|
"icon_color": "--el-text-color-regular",
|
|
233
241
|
"hover_bg": "--el-fill-color-light"
|
|
@@ -242,7 +250,7 @@
|
|
|
242
250
|
"text_font_size": "font-size",
|
|
243
251
|
"text_color": "color"
|
|
244
252
|
},
|
|
245
|
-
"$note": "inline_switch 并排切换形态:下拉项平铺为触发器旁切换组,按 v-if
|
|
253
|
+
"$note": "inline_switch 并排切换形态:下拉项平铺为触发器旁切换组,按 v-if 保形切换增量插入,不删原代码;同时收口图标形态与下拉浮层(浮层属 teleport popper,样式层管不到,必须结构收口)。"
|
|
246
254
|
},
|
|
247
255
|
"admin.layout.header.collapse_trigger": {
|
|
248
256
|
"type": "element",
|
|
@@ -414,7 +422,7 @@
|
|
|
414
422
|
"file": "src/layout/components/lay-tag/index.vue(首页页签 = multiTags 首项;真实 DOM 无 .home-item 类)",
|
|
415
423
|
"style_scope": ".tags-view",
|
|
416
424
|
"forms": {
|
|
417
|
-
"icon_only": "出厂默认:首位渲染 home 图标(meta.icon 缺省回退 'home'
|
|
425
|
+
"icon_only": "出厂默认:首位渲染 home 图标(meta.icon 缺省回退 'home'),无标题文字。**落地必须成对:显图标 + 隐标题**——项宽固定 40px,图标与标题共存时标题会被挤成截断(如「工作台」→「工…」)。隐标题经 text_visible 字段(display)落 .card-tab 标题元素;只显图标不收标题 = 形态未落地",
|
|
418
426
|
"text": "纯文字首位页签(隐藏图标且不占位)"
|
|
419
427
|
},
|
|
420
428
|
"$comment": "真实 DOM:.scroll-item(首项)> .card-tab,图标经 resolveCardTabIcon 渲染 svg-icon.card-tab-icon。出厂解析规则是数据驱动的:index===0 回退 'home',其余按 meta.icon 有无渲染(空则不渲染不占位)——因此「有无图标」是-menu 数据/变更集可动态决定的,不是源码常量。尺寸类意图(如 40×40)由变更集 style 字段给出,词典不预设具体设计值。",
|
|
@@ -422,12 +430,17 @@
|
|
|
422
430
|
"width": "--vts-tag-card-height",
|
|
423
431
|
"height": "--vts-tag-card-height"
|
|
424
432
|
},
|
|
433
|
+
"sub_selectors": {
|
|
434
|
+
"icon": ".card-tab .card-tab-icon",
|
|
435
|
+
"title": ".card-tab .tag-title"
|
|
436
|
+
},
|
|
425
437
|
"custom_style_map": {
|
|
426
438
|
"icon_size": "font-size",
|
|
427
439
|
"icon_color": "color",
|
|
428
|
-
"divider_color": "border-left-color"
|
|
440
|
+
"divider_color": "border-left-color",
|
|
441
|
+
"text_visible": "display"
|
|
429
442
|
},
|
|
430
|
-
"$note": "divider(首页页签与首个业务页签间的 1px 竖线分割)属子元素结构样式,设计侧通过 divider_color 表达视觉意图。落地口径:首页页签项宽=高=--vts-tag-card-height(40px) 居中,经 .tags-view .scroll-item:nth-child(1) 覆写 padding
|
|
443
|
+
"$note": "divider(首页页签与首个业务页签间的 1px 竖线分割)属子元素结构样式,设计侧通过 divider_color 表达视觉意图。落地口径:首页页签项宽=高=--vts-tag-card-height(40px) 居中,经 .tags-view .scroll-item:nth-child(1) 覆写 padding 实现正方形图标钮;icon_only 形态必须同时把标题收口(text_visible=none),否则 40px 内图标+标题并存、标题被截断。"
|
|
431
444
|
},
|
|
432
445
|
"admin.layout.tabs.item": {
|
|
433
446
|
"type": "element",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qilitt-mickey/vue3-temp-skill",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.64",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Vue 3 企业级中后台项目开发规范技能包 — core-kernel 架构、按需功能模块",
|
|
6
6
|
"bin": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"check": "node scripts/check.mjs",
|
|
31
31
|
"prepack": "node scripts/check.mjs",
|
|
32
32
|
"validate": "node scripts/validate.mjs",
|
|
33
|
+
"audit": "node scripts/design-audit.mjs",
|
|
33
34
|
"match": "python scripts/match.py",
|
|
34
35
|
"match:batch": "python scripts/match.py --batch",
|
|
35
36
|
"mapping:build": "node parser/index.js build",
|
|
@@ -37,7 +37,19 @@
|
|
|
37
37
|
- [ ] **结构施工兑现检查(structure_ops 逐条对账,与红线同级)**:变更集 `structure_ops[]` 的**每一条 op** 都必须在交付说明给出「op → 落在哪个文件 / 哪个节点 / 怎么落的」;`add_node` 必须真的新增了元素或渲染分支(模板 diff 可验),`bind_source` 必须真的改了绑定(`class-name` / `align` / `formatter`)。出现「声明了 op、代码里找不到对应改动」= 交付不完整,禁止只改 CSS 冒充结构施工
|
|
38
38
|
- [ ] **同权重失效检查( · 专查「写了不生效」)**:逐条核对设计层新增规则是否真能取胜——① 对手是组件 `<style scoped>` 时,本条特异度必须**严格高于**它(**平手即错**,因为组件样式在全局之后注入);② 对手带 `!important` 时本条必须同样 `!important`,或改走通道变量。**判定方法(必须真数,不许目测)**:对每条新规则与其对手分别写出特异度三元组 `(a,b,c)` 并**逐位比较(先 b 后 c)**,本条的 `b` 不大于对手即判为「失效」;`html` / `body` 只加 `c` 位,**对手 `b` 更高时加 `html body` 无效**,必须靠**加类**(祖先链补稳定语义类,如 `.el-menu`)把 `b` 抬到相等。同 `b` 时,`c` 更高者胜。③ **出厂用语义令牌间接设置该属性时(`var(--vts-theme-*)`),必须先走「令牌重映射」而不是抢属性**——在令牌宿主作用域重声明令牌即生效、零权重战争;只有无令牌或令牌语义不符时才抢属性。典型失效例:面包屑 `display:none`(与组件 scoped 同为 0,2,0)→ 照旧显示;页签关闭钮 `opacity`(同为 0,5,0)→ 仍要 hover 才显;页签项 `padding`(对手带 `!important`)→ 无论怎么抬特异度都改不动;**侧栏菜单 `color`(`b=2` 输给出厂 `b=3` + `!important`)→ 整栏菜单文字用浅色主题的黑色落在深色侧栏上、字全部消失**
|
|
39
39
|
- [ ] **oracle 基线可用性检查**:变更集带 `oracle` 时——① `oracle.mockup` 必须与**本次主题、本次页面**一致;**同构异色复用除外**:变更集声明 `oracle.theme_mapping: { "from": "<效果图主题>", "to": "<本次主题>" }`(同场景 / 同页面 / 同角色集合、仅主题色板不同)时,允许复用 from 主题效果图,verify 必须带 `--from-theme/--to-theme` 做色值归一,**不得因换主题色就判死闭环**;结构 / 形态有差异时禁止复用;② `oracle.sids` 与 `changes[]` **双向闭合**:效果图里有、changes[] 未声明 = **欠声明**(该点位永远不可能被还原);changes[] 已声明、效果图无 sid = **无基线**——**仅 `visible:false` 的隐藏点位豁免**(效果图上无可见元素可标 `data-sid`,parser 已自动豁免并打印清单)。其余两类出现即回流设计侧补齐,**不得直接开工**(`parser match` 已自动报出);③ **状态性期望与孤儿对账(G14/G15,抽取产物层)**:效果图上有可见状态性表现的点位(禁用置灰 / 激活 / 选中)→ `oracle.json.states` 必须有对应 `<sid>@<state>` 期望(缺失 = 状态维度放弃验收);交付前核对项目页渲染的每个区块都在 oracle 全集(sids ∪ absent ∪ residual ∪ states)内——孤儿点位 verify 当场 exit 1,提前自查可省一轮返工
|
|
40
|
-
- [ ]
|
|
40
|
+
- [ ] **设计层闭环审计闸(机器断言,ERROR 必须清零)**:施工完成后跑 `node scripts/design-audit.mjs --project <落地项目> --container-width <内容区实际宽>`,或 `VUE3_AUDIT_PROJECT=<落地项目> npm run check`。本闸把「声明了 ≠ 生效」拆成逐条静态断言,替代肉眼目视,任一 ERROR 即视为未交付。**与 `parser/index.js audit` 的分工**:parser audit 管「通道与挂载点」(selector 有没有挂载点、变量有没有消费者),本闸管「生效性与形态」(层路由、层叠胜负、列宽预算、收口断言、形态成对);E1/E4 是两者的故意重叠项,用作交叉校验:
|
|
41
|
+
- E1 变量悬空 → 使用了项目内无定义且无 `var()` 回退的自有变量,整条声明失效
|
|
42
|
+
- E2 层错位 → 内层盒属性族(`white-space` / `text-overflow` / `overflow` / `text-align` / `line-height` / `vertical-align` / `word-break`)写在表格宿主盒 `th/td.el-table__cell` 上而非 `> .cell`,会被 Element Plus 内层 `.cell` 覆盖
|
|
43
|
+
- E3 **层叠败北** → 本条声明的作用元素上的特异度 ≤ 组件 scoped 竞争者的实际特异度(scoped 编译会给末位选择器追加 `[data-v]`,等价 +1 类级;平手判负,因为组件样式晚于全局注入)
|
|
44
|
+
- E4 选择器挂空 → 设计层使用的项目类名在 `src/` 无挂载点(锚点必须锚「实际在」)
|
|
45
|
+
- E5 **列宽预算** → 存在既无 `width` 又无 `min-width` 的业务列;或 `min-width` 小于该列表头文案的最小占宽;或列宽合计溢出容器而未见滚动策略
|
|
46
|
+
- E6 收口断言 → 变更集 `visible:false` 的点位必须在设计层找到「命中该锚点且 `display:none !important`」的落点
|
|
47
|
+
- E7 激活态越层 → 激活胶囊/指示条写到了可展开分组的标题上(`.el-sub-menu__title` 须显式排除)
|
|
48
|
+
- E8 浮层残留 → 声明收口下拉浮层的 form,组件却仍保留 `ElDropdown` / `#dropdown` 消费(teleport 浮层样式层收不掉,必须结构收口)
|
|
49
|
+
- E9 形态收口缺件 → `icon_only` 只落了「显图标」而没有**在同一作用域内**收口文案(作用域外的兄弟变体收口不算成对)
|
|
50
|
+
- E10 scoped 挂空 → 组件 scoped 规则依赖运行时并不存在的类(结构重构后残留,永不命中)
|
|
51
|
+
- [ ] **列宽由表头文案反推(E5 落地口径)**:每个业务列(含选择列、序号列)都要显式声明 `width` 或 `min-width`,取值 ≥ 该列表头文案宽度 + 左右各 16px 内边距(CJK 按 1em、ASCII 按 0.6em 估)。无宽列只能拿出厂兜底宽度参与布局,会被相邻固定宽列挤压——**表头换行与省略号截断的唯一根因就在这里**,不接受「跑起来看着还行」
|
|
52
|
+
|
|
41
53
|
- [ ] **缺口清单只含意图缺口**:清单每一项都必须是「设计侧未给值 / 值非法」且已注明「回流设计侧补齐」;能力缺口(词典缺 ID / 缺字段枚举 / 缺 form)不得出现在清单里——出现即把可落地项搁置
|
|
42
54
|
- [ ] **台账逐行兑现对账(对账闸门)**:把设计侧交付的提取台账逐行过一遍,每行必须有落地结论——已落地(写明落在哪个文件/选择器)或已入缺口清单(写明缺失字段)。存在「台账有行、落地无结论」的行 = 交付不完整,与红线同级
|
|
43
55
|
- [ ] **登记表覆盖率对账(子项级细闸,)**:带登记表交付时必须跑 `match --registry`——`C = A` 且无缺口才可交付;同时确认无「效果图标注缺口」(`always:true` 子项在效果图上都有同名 `data-sid`)。**禁止采信设计侧自报的 `coverage` 数字**(parser 会当场重算,自报数不参与);缺口逐条点名回流设计侧
|
|
@@ -71,5 +71,7 @@ node parser/index.js verify --oracle oracle.json --actual actual.json --baseline
|
|
|
71
71
|
**夹具与设计资产的边界(职责单侧沉淀红线)**:`「页面名-主题」`命名法(如 `运单管理-钴蓝.html`)是设计侧效果图库的专属资产命名——**项目侧(含 parser/fixtures 回归夹具)禁止出现与设计侧资产同名的文件**。回归需要 mockup 实物时,一律用「回归夹具-」前缀合成名(如 `回归夹具-列表页-钴蓝.html`),点集为回归机制定制、与真基线**不同且不可互替**;真实效果图基线唯一沉淀在设计侧 `page-specs/styles/<方案>/scenes/<场景>/mockups/`,项目侧 `oracle.mockup` 指向它靠 `resolveFirstExisting` 跨库解析(变更集同目录 → cwd → 技能根 → 设计侧兄弟目录),不做本地拷贝。
|
|
72
72
|
|
|
73
73
|
- 验收结果写入最终回复(命中 N / 缺口 M / 归因分布 / 修正)与 `code-quality` 结果块;**不得只报「已完成」**。
|
|
74
|
+
- **入场前置:闭环审计闸必须已清零**。`node scripts/design-audit.mjs --project <落地项目> --container-width <内容区实际宽>` 存在 ERROR 时不得进入渲染态验收——层错位 / 层叠败北 / 无宽列这类失效会让 verify 侧出现「期望值取不到」或「值对但不生效」的假缺口,先修闸门再比对,否则每轮都在给同一个根因换皮。
|
|
74
75
|
- 变更集无 `oracle` 时输出 `N/A`(无基线),并在交付说明注明「无验收基线」——这是设计侧应补的项,回流设计侧。
|
|
75
76
|
- 静态审计(audit)与渲染态验收(verify)**互补**:audit 查「通道是否存在」,verify 查「渲染结果是否一致」,两者都过才算闭环。
|
|
77
|
+
- **三闸自洽口径(无 oracle 时也不放行)**:`mapping coverage` 全绿只证明「点位被覆盖」,`parser audit` 全绿只证明「通道与挂载点存在」,`design-audit` 全绿只证明「声明具备生效条件」——三者都不证明「渲染结果与效果图一致」。缺 `oracle` 的批次必须显式声明「本轮无渲染态基线」,不得用前两闸的绿灯冒充闭环。
|
package/scripts/check.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { execFileSync } from "child_process";
|
|
13
|
+
import fs from "fs";
|
|
13
14
|
import path from "path";
|
|
14
15
|
import { fileURLToPath } from "url";
|
|
15
16
|
|
|
@@ -19,6 +20,15 @@ const NODE = process.execPath;
|
|
|
19
20
|
const PYTHON = process.env.VUE3_SKILL_PYTHON || (process.platform === "win32" ? "py" : "python3");
|
|
20
21
|
const PYTHON_ARGS = process.platform === "win32" && !process.env.VUE3_SKILL_PYTHON ? ["-3"] : [];
|
|
21
22
|
|
|
23
|
+
/** 设计层闭环审计的目标项目:默认当前目录,可用 VUE3_AUDIT_PROJECT 指向落地项目 */
|
|
24
|
+
function resolveAuditProject() {
|
|
25
|
+
const dir = process.env.VUE3_AUDIT_PROJECT || process.cwd();
|
|
26
|
+
const designDir = path.join(dir, ".codebuddy", "design");
|
|
27
|
+
if (!fs.existsSync(designDir)) return null;
|
|
28
|
+
const has = fs.readdirSync(designDir).some((f) => /^changeset.*\.json$/i.test(f));
|
|
29
|
+
return has ? dir : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
const checks = [
|
|
23
33
|
{
|
|
24
34
|
name: "结构校验",
|
|
@@ -30,6 +40,19 @@ const checks = [
|
|
|
30
40
|
desc: "高频 Demo 场景能力组命中验证",
|
|
31
41
|
cmd: () => run("python", "scripts/match.py", ["--batch"]),
|
|
32
42
|
},
|
|
43
|
+
{
|
|
44
|
+
name: "设计层闭环审计",
|
|
45
|
+
desc: "变量悬空/层错位/层叠胜负/锚点挂空/列宽预算/收口断言(无落地项目则跳过)",
|
|
46
|
+
cmd: () => {
|
|
47
|
+
const project = resolveAuditProject();
|
|
48
|
+
if (!project) return "SKIPPED 未发现落地项目(.codebuddy/design/changeset*.json);用 VUE3_AUDIT_PROJECT=<落地项目> 指定后复跑";
|
|
49
|
+
const args = ["--project", project];
|
|
50
|
+
if (process.env.VUE3_AUDIT_CONTAINER_WIDTH) {
|
|
51
|
+
args.push("--container-width", process.env.VUE3_AUDIT_CONTAINER_WIDTH);
|
|
52
|
+
}
|
|
53
|
+
return run(NODE, "scripts/design-audit.mjs", args);
|
|
54
|
+
},
|
|
55
|
+
},
|
|
33
56
|
];
|
|
34
57
|
|
|
35
58
|
function run(runner, script, args = []) {
|
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 设计层闭环审计 — 「样式逻辑 → 效果」的生效性断言。
|
|
5
|
+
*
|
|
6
|
+
* 发布闸(scripts/validate.mjs)校验的是技能包自身结构;本脚本校验的是
|
|
7
|
+
* **一次落地是否真的把变更集落成了有效样式**:声明了不等于生效,生效了不等于对。
|
|
8
|
+
* 它只做静态分析,不依赖浏览器与开发服务器。
|
|
9
|
+
*
|
|
10
|
+
* 用法:
|
|
11
|
+
* node scripts/design-audit.mjs # 审计当前目录(落地项目根)
|
|
12
|
+
* node scripts/design-audit.mjs --project <dir> # 指定落地项目
|
|
13
|
+
* node scripts/design-audit.mjs --json # 机器可读输出
|
|
14
|
+
* node scripts/design-audit.mjs --container-width 1624 # 表格容器可用宽(列宽预算用)
|
|
15
|
+
* node scripts/design-audit.mjs --report <file.md> # 另存 Markdown 报告(可选,便于交付留档)
|
|
16
|
+
*
|
|
17
|
+
* 退出码:0 = 无 ERROR;1 = 存在 ERROR。
|
|
18
|
+
*
|
|
19
|
+
* 审计项:
|
|
20
|
+
* E1 变量悬空 设计层 var(--x) 项目内无定义且无回退
|
|
21
|
+
* E2 层错位 内层盒属性族写在表格宿主盒上(会被 EP 内层 .cell 覆盖)
|
|
22
|
+
* E3 层叠败北 设计层声明特指度低于组件 scoped 同名类规则(含 [data-v] 加成)→ 不生效
|
|
23
|
+
* E4 选择器挂空 设计层使用的项目类名在 src 中不存在(假锚点)
|
|
24
|
+
* E5 列宽预算 存在无宽列(既无 width 又无 min-width);Σ列宽超出容器
|
|
25
|
+
* E6 收口断言 visible:false 的 sid 必须有 display:none(+提权)落点
|
|
26
|
+
* E7 激活态越层 分组标题被写入叶子项的激活态(胶囊/指示条)
|
|
27
|
+
* E8 浮层残留 form 要求收口下拉浮层,但组件仍消费 #dropdown
|
|
28
|
+
* E9 形态收口缺件 form=icon_only 只显图标、未在同一作用域内收口文案(且不被兄弟变体规则骗过)
|
|
29
|
+
* E10 scoped 挂空 组件 scoped 规则依赖运行时并不存在的类(重构残留,永不命中)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import fs from "fs";
|
|
33
|
+
import path from "path";
|
|
34
|
+
import { fileURLToPath } from "url";
|
|
35
|
+
|
|
36
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const SKILL_ROOT = path.resolve(__dirname, "..");
|
|
38
|
+
|
|
39
|
+
const argv = process.argv.slice(2);
|
|
40
|
+
const argValue = (name, def) => {
|
|
41
|
+
const i = argv.indexOf(name);
|
|
42
|
+
return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
|
|
43
|
+
};
|
|
44
|
+
const PROJECT = path.resolve(argValue("--project", process.cwd()));
|
|
45
|
+
const AS_JSON = argv.includes("--json");
|
|
46
|
+
const CONTAINER_EXPLICIT = argv.includes("--container-width");
|
|
47
|
+
const CONTAINER_WIDTH = Number(argValue("--container-width", "1200")) || 1200;
|
|
48
|
+
|
|
49
|
+
const DESIGN_LAYER_GLOBS = [
|
|
50
|
+
"src/styles/design-shell.scss",
|
|
51
|
+
"src/styles/design-tokens.scss",
|
|
52
|
+
"src/styles/dark.scss",
|
|
53
|
+
];
|
|
54
|
+
const SRC_DIRS = ["src"];
|
|
55
|
+
const IGNORES = new Set(["node_modules", ".git", "dist", "build", ".vite", "public"]);
|
|
56
|
+
const SRC_EXT = new Set([".vue", ".ts", ".tsx", ".js", ".mjs", ".scss", ".css", ".html"]);
|
|
57
|
+
const JSON_EXT = new Set([".json"]);
|
|
58
|
+
|
|
59
|
+
/** 内层盒属性族:必须落 EP 内层 .cell,落宿主 th/td 会被 .cell 覆盖 */
|
|
60
|
+
const INNER_ONLY_PROPS = new Set([
|
|
61
|
+
"white-space",
|
|
62
|
+
"text-overflow",
|
|
63
|
+
"overflow",
|
|
64
|
+
"word-break",
|
|
65
|
+
"text-align",
|
|
66
|
+
"line-height",
|
|
67
|
+
"vertical-align",
|
|
68
|
+
"text-align-last",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/** 项目自有变量前缀(--el-* 为出厂,不参与悬空判定) */
|
|
72
|
+
const OWNED_VAR_PREFIXES = ["--ds-", "--vts-", "--re-", "--brand-", "--table-", "--pagination-", "--filter-", "--page-", "--action-bar-", "--empty-", "--menu-", "--sidebar-"];
|
|
73
|
+
|
|
74
|
+
/** 项目自有类名前缀(用于选择器挂空判定) */
|
|
75
|
+
const OWNED_CLASS_PREFIXES = ["vts-", "re-", "col-", "navbar-", "header-", "sider-", "sidebar-", "menu-", "card-tab", "tab-", "language-", "app-", "list-"];
|
|
76
|
+
|
|
77
|
+
const errors = [];
|
|
78
|
+
const warnings = [];
|
|
79
|
+
const infos = [];
|
|
80
|
+
|
|
81
|
+
const err = (id, msg) => errors.push({ id, msg });
|
|
82
|
+
const warn = (id, msg) => warnings.push({ id, msg });
|
|
83
|
+
const info = (id, msg) => infos.push({ id, msg });
|
|
84
|
+
|
|
85
|
+
// ── 文件工具 ────────────────────────────────────────────────
|
|
86
|
+
function walk(dir, out = [], exts = SRC_EXT) {
|
|
87
|
+
if (!fs.existsSync(dir)) return out;
|
|
88
|
+
for (const name of fs.readdirSync(dir)) {
|
|
89
|
+
if (IGNORES.has(name)) continue;
|
|
90
|
+
const full = path.join(dir, name);
|
|
91
|
+
let st;
|
|
92
|
+
try {
|
|
93
|
+
st = fs.statSync(full);
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (st.isDirectory()) walk(full, out, exts);
|
|
98
|
+
else if (exts.has(path.extname(name))) out.push(full);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const readIf = (p) => (fs.existsSync(p) ? fs.readFileSync(p, "utf-8") : null);
|
|
104
|
+
|
|
105
|
+
// ── 迷你 CSS/SCSS 展平器 ────────────────────────────────────
|
|
106
|
+
function stripComments(css) {
|
|
107
|
+
return css
|
|
108
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
109
|
+
.replace(/(^|[\s;}])[/]{2}[^\n]*/g, "$1");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function addDecls(decls, chunk) {
|
|
113
|
+
const text = String(chunk);
|
|
114
|
+
if (!text.trim()) return;
|
|
115
|
+
for (const piece of text.split(";")) {
|
|
116
|
+
const t = piece.trim();
|
|
117
|
+
if (!t || t.startsWith("$") || t.startsWith("@")) continue;
|
|
118
|
+
const idx = t.indexOf(":");
|
|
119
|
+
if (idx <= 0) continue;
|
|
120
|
+
const prop = t.slice(0, idx).trim().toLowerCase();
|
|
121
|
+
const value = t.slice(idx + 1).trim();
|
|
122
|
+
if (!prop || /[{}]/.test(prop)) continue;
|
|
123
|
+
decls[prop] = value;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const splitList = (s) => String(s).split(",").map((x) => x.trim()).filter(Boolean);
|
|
128
|
+
|
|
129
|
+
function joinSelector(parent, child) {
|
|
130
|
+
const parts = splitList(child);
|
|
131
|
+
if (!parent) return parts.join(", ");
|
|
132
|
+
const parents = splitList(parent);
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const part of parts) {
|
|
135
|
+
if (part.includes("&")) {
|
|
136
|
+
for (const p of parents) out.push(part.replace(/&/g, p));
|
|
137
|
+
} else {
|
|
138
|
+
for (const p of parents) out.push(`${p} ${part}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out.join(", ");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 把一段 SCSS 正文拆成「本层声明」与「子块」。
|
|
146
|
+
* 关键:声明与子块是交错的,必须逐字符按 `;` / `{}` 切分,
|
|
147
|
+
* 否则「先声明后嵌套」的规则会把声明粘到下一个子选择器上(错位出幽灵规则)。
|
|
148
|
+
*/
|
|
149
|
+
function parseRule(body) {
|
|
150
|
+
const blocks = [];
|
|
151
|
+
let declText = "";
|
|
152
|
+
let buf = "";
|
|
153
|
+
let i = 0;
|
|
154
|
+
while (i < body.length) {
|
|
155
|
+
const ch = body[i];
|
|
156
|
+
if (ch === '"' || ch === "'") {
|
|
157
|
+
let j = i + 1;
|
|
158
|
+
while (j < body.length && body[j] !== ch) j += body[j] === "\\" ? 2 : 1;
|
|
159
|
+
buf += body.slice(i, j + 1);
|
|
160
|
+
i = j + 1;
|
|
161
|
+
} else if (ch === "{" && buf.endsWith("#")) {
|
|
162
|
+
buf += ch; // SCSS 插值 #{},不是块
|
|
163
|
+
i++;
|
|
164
|
+
} else if (ch === "{") {
|
|
165
|
+
const sel = buf.trim();
|
|
166
|
+
buf = "";
|
|
167
|
+
let depth = 1;
|
|
168
|
+
let j = i + 1;
|
|
169
|
+
let inner = "";
|
|
170
|
+
while (j < body.length && depth > 0) {
|
|
171
|
+
const c = body[j];
|
|
172
|
+
if (c === "{") depth++;
|
|
173
|
+
else if (c === "}") {
|
|
174
|
+
depth--;
|
|
175
|
+
if (depth === 0) break;
|
|
176
|
+
}
|
|
177
|
+
inner += c;
|
|
178
|
+
j++;
|
|
179
|
+
}
|
|
180
|
+
if (sel) blocks.push({ sel, body: inner });
|
|
181
|
+
i = j + 1;
|
|
182
|
+
} else if (ch === ";") {
|
|
183
|
+
declText += buf + ";";
|
|
184
|
+
buf = "";
|
|
185
|
+
i++;
|
|
186
|
+
} else if (ch === "}") {
|
|
187
|
+
i++;
|
|
188
|
+
} else {
|
|
189
|
+
buf += ch;
|
|
190
|
+
i++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
declText += buf;
|
|
194
|
+
return { declText, blocks };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const AT_SKIP = /^@(keyframes|-webkit-keyframes|-moz-keyframes|-o-keyframes|font-face|import|use|forward|charset|tailwind|apply|extend)/i;
|
|
198
|
+
|
|
199
|
+
function flattenCss(css, parentSel = "", acc = []) {
|
|
200
|
+
const { declText, blocks } = parseRule(stripComments(css));
|
|
201
|
+
if (parentSel && declText.trim()) {
|
|
202
|
+
const decls = {};
|
|
203
|
+
addDecls(decls, declText);
|
|
204
|
+
if (Object.keys(decls).length) acc.push({ selector: parentSel, decls });
|
|
205
|
+
}
|
|
206
|
+
for (const b of blocks) {
|
|
207
|
+
const sel = b.sel.trim();
|
|
208
|
+
if (!sel || AT_SKIP.test(sel)) continue;
|
|
209
|
+
if (sel.startsWith("@")) {
|
|
210
|
+
flattenCss(b.body, parentSel, acc);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
flattenCss(b.body, joinSelector(parentSel, sel), acc);
|
|
214
|
+
}
|
|
215
|
+
return acc;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const classTokens = (selector) => [...selector.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)].map((m) => m[1]);
|
|
219
|
+
/** 去掉伪元素后的「末位复合选择器」——它才是这条规则真正作用的那一层元素 */
|
|
220
|
+
const lastCompound = (sel) => {
|
|
221
|
+
const parts = String(sel)
|
|
222
|
+
.replace(/::[a-z-]+(\([^)]*\))?/gi, "")
|
|
223
|
+
.split(/[\s>+~]+/)
|
|
224
|
+
.filter(Boolean);
|
|
225
|
+
return parts[parts.length - 1] || "";
|
|
226
|
+
};
|
|
227
|
+
const allVars = (text) => [...text.matchAll(/var\(\s*(--[\w-]+)\s*(,)?/g)].map((m) => ({ name: m[1], hasFallback: !!m[2] }));
|
|
228
|
+
|
|
229
|
+
// ── 采集:设计层 / 项目源 / 变更集 / 词典 ────────────────────
|
|
230
|
+
const designFiles = DESIGN_LAYER_GLOBS.map((g) => path.join(PROJECT, g)).filter((p) => fs.existsSync(p));
|
|
231
|
+
const extraDesign = walk(path.join(PROJECT, "src", "styles")).filter((p) => /design-.*\.(scss|css)$/i.test(path.basename(p)));
|
|
232
|
+
const DESIGN_FILES = [...new Set([...designFiles, ...extraDesign])];
|
|
233
|
+
|
|
234
|
+
const srcFiles = SRC_DIRS.flatMap((d) => walk(path.join(PROJECT, d)));
|
|
235
|
+
const srcTextByFile = new Map();
|
|
236
|
+
for (const f of srcFiles) srcTextByFile.set(f, readIf(f) ?? "");
|
|
237
|
+
|
|
238
|
+
const designSet = new Set(DESIGN_FILES.map((p) => path.resolve(p)));
|
|
239
|
+
const projectText = [...srcTextByFile.entries()]
|
|
240
|
+
.filter(([f]) => !designSet.has(path.resolve(f)))
|
|
241
|
+
.map(([, t]) => t)
|
|
242
|
+
.join("\n");
|
|
243
|
+
|
|
244
|
+
const designCss = DESIGN_FILES.map((f) => srcTextByFile.get(f) ?? readIf(f) ?? "").join("\n");
|
|
245
|
+
// 每条规则带上来源文件:用于区分「本次落地的设计层」与「历史遗留层」的告警级别
|
|
246
|
+
const designRules = DESIGN_FILES.flatMap((f) =>
|
|
247
|
+
flattenCss(srcTextByFile.get(f) ?? readIf(f) ?? "").map((r) => ({ ...r, file: f })),
|
|
248
|
+
);
|
|
249
|
+
const PRIMARY_LAYER = path.resolve(PROJECT, argValue("--design-layer", "src/styles/design-shell.scss"));
|
|
250
|
+
const isPrimary = (file) => path.resolve(file) === PRIMARY_LAYER;
|
|
251
|
+
|
|
252
|
+
// 变更集(取 .codebuddy/design 下第一个 changeset-*.json)
|
|
253
|
+
const designDir = path.join(PROJECT, ".codebuddy", "design");
|
|
254
|
+
let changeset = null;
|
|
255
|
+
let changesetFile = null;
|
|
256
|
+
if (fs.existsSync(designDir)) {
|
|
257
|
+
const cand = fs.readdirSync(designDir).filter((f) => /^changeset.*\.json$/i.test(f)).sort();
|
|
258
|
+
if (cand.length) {
|
|
259
|
+
changesetFile = path.join(designDir, cand[0]);
|
|
260
|
+
try {
|
|
261
|
+
changeset = JSON.parse(fs.readFileSync(changesetFile, "utf-8"));
|
|
262
|
+
} catch (e) {
|
|
263
|
+
err("E0", `变更集解析失败:${changesetFile} — ${e.message}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 词典(技能包自带 mapping/)
|
|
269
|
+
const dictSidMap = new Map();
|
|
270
|
+
const dictEntries = [];
|
|
271
|
+
(function loadMapping() {
|
|
272
|
+
const roots = ["mapping/scenes", "mapping/component", "mapping/layout", "mapping/basic"].map((r) => path.join(SKILL_ROOT, r));
|
|
273
|
+
const files = roots.flatMap((r) => walk(r, [], JSON_EXT));
|
|
274
|
+
for (const f of files) {
|
|
275
|
+
try {
|
|
276
|
+
const json = JSON.parse(fs.readFileSync(f, "utf-8"));
|
|
277
|
+
for (const [sid, entry] of Object.entries(json.mappings || {})) {
|
|
278
|
+
if (!entry || typeof entry !== "object") continue;
|
|
279
|
+
dictSidMap.set(sid, { ...entry, __file: f });
|
|
280
|
+
dictEntries.push({ sid, entry });
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
/* 忽略无法解析的映射文件 */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
})();
|
|
287
|
+
|
|
288
|
+
// ── E1 变量悬空 ─────────────────────────────────────────────
|
|
289
|
+
(function checkE1() {
|
|
290
|
+
if (!designCss) return;
|
|
291
|
+
const defined = new Set();
|
|
292
|
+
for (const t of [designCss, projectText].filter(Boolean)) {
|
|
293
|
+
for (const m of t.matchAll(/(--[\w-]+)\s*:/g)) defined.add(m[1]);
|
|
294
|
+
}
|
|
295
|
+
if (changeset) {
|
|
296
|
+
// 令牌名可能只在变更集里出现(由项目侧再映射),一并视为已声明
|
|
297
|
+
for (const m of JSON.stringify(changeset).matchAll(/--[\w-]+/g)) defined.add(m[0]);
|
|
298
|
+
}
|
|
299
|
+
const missing = new Map();
|
|
300
|
+
for (const { name, hasFallback } of allVars(designCss)) {
|
|
301
|
+
if (hasFallback || defined.has(name)) continue;
|
|
302
|
+
if (!OWNED_VAR_PREFIXES.some((p) => name.startsWith(p))) continue;
|
|
303
|
+
missing.set(name, (missing.get(name) || 0) + 1);
|
|
304
|
+
}
|
|
305
|
+
for (const [name, count] of missing) {
|
|
306
|
+
err("E1", `变量悬空:设计层使用了 ${name}(${count} 处),但项目内无定义、也无 var() 回退 —— 该声明整条失效。`);
|
|
307
|
+
}
|
|
308
|
+
if (!missing.size) info("E1", `变量悬空:0 处(受检前缀 ${OWNED_VAR_PREFIXES.join(" ")})`);
|
|
309
|
+
})();
|
|
310
|
+
|
|
311
|
+
// ── E2 层错位 ───────────────────────────────────────────────
|
|
312
|
+
(function checkE2() {
|
|
313
|
+
const hits = [];
|
|
314
|
+
for (const rule of designRules) {
|
|
315
|
+
const sel = rule.selector;
|
|
316
|
+
if (!/el-table|(^|[\s,>])(th|td)(\.|[\s,:{]|$)/.test(sel)) continue;
|
|
317
|
+
if (/\.cell\b/.test(sel)) continue; // 已锚定内层文本盒
|
|
318
|
+
const offenders = Object.keys(rule.decls).filter((p) => INNER_ONLY_PROPS.has(p));
|
|
319
|
+
if (offenders.length) hits.push({ sel, offenders });
|
|
320
|
+
}
|
|
321
|
+
for (const h of hits.slice(0, 12)) {
|
|
322
|
+
err("E2", `层错位:${h.sel} 声明了内层盒属性族 [${h.offenders.join(", ")}],但未锚定 > .cell —— 会被 Element Plus 内层 .cell 覆盖,等于没写。`);
|
|
323
|
+
}
|
|
324
|
+
if (hits.length > 12) err("E2", `层错位:另有 ${hits.length - 12} 处未列出。`);
|
|
325
|
+
if (!hits.length) info("E2", "层错位:0 处");
|
|
326
|
+
})();
|
|
327
|
+
|
|
328
|
+
// ── E3 层叠胜负 / E10 scoped 挂空 ───────────────────────────
|
|
329
|
+
// 组件 scoped 规则编译后末位选择器会追加 [data-v-xxx] 属性选择器,等价 +1 个类级特指度。
|
|
330
|
+
// 因此「设计层声明是否真的生效」必须算特指度,不能只看有没有同名类。
|
|
331
|
+
const SCOPED_ATTR = 1000;
|
|
332
|
+
const STATE_PSEUDO = /:(hover|focus|active|visited|disabled|checked|focus-within|focus-visible|first-child|last-child|nth-[\w-]+)/i;
|
|
333
|
+
|
|
334
|
+
const pseudoElOf = (sel) => (sel.match(/::([a-z-]+)/i) || [])[1] || "";
|
|
335
|
+
|
|
336
|
+
function specOf(selector) {
|
|
337
|
+
const s = selector
|
|
338
|
+
.replace(/::[a-z-]+(\([^)]*\))?/gi, " ")
|
|
339
|
+
.replace(/:not\(([^)]*)\)/g, ":not");
|
|
340
|
+
const ids = (s.match(/#[\w-]+/g) || []).length;
|
|
341
|
+
const classes =
|
|
342
|
+
(s.match(/\.[\w-]+/g) || []).length +
|
|
343
|
+
(s.match(/\[[^\]]*\]/g) || []).length +
|
|
344
|
+
(s.match(/:(?!:)[\w-]+(\([^)]*\))?/g) || []).length;
|
|
345
|
+
const elems = (s.match(/(?:^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
|
|
346
|
+
return ids * 1e6 + classes * 1e3 + elems;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 状态类 / 第三方类不属于「项目自有挂载点」,挂空判定不参与 */
|
|
350
|
+
const isOwnedClass = (c) =>
|
|
351
|
+
OWNED_CLASS_PREFIXES.some((p) => c.startsWith(p)) && !/^(is|has|van|el|n)-/.test(c);
|
|
352
|
+
const IGNORE_DEAD = new Set(["language-text", "language-text__item"]);
|
|
353
|
+
|
|
354
|
+
// 模板/脚本源码(剔掉 style 块,避免样式里的类名自证「已挂载」)
|
|
355
|
+
const markupText = [...srcTextByFile.entries()]
|
|
356
|
+
.filter(([f]) => f.endsWith(".vue"))
|
|
357
|
+
.map(([, t]) => t.replace(/<style[\s\S]*?<\/style>/g, ""))
|
|
358
|
+
.join("\n");
|
|
359
|
+
|
|
360
|
+
// 收集组件 scoped 规则,并标记「挂空」的(选择器链条上存在运行时不存在的类)
|
|
361
|
+
const scopedRules = [];
|
|
362
|
+
const scopedDead = [];
|
|
363
|
+
for (const [f, t] of srcTextByFile) {
|
|
364
|
+
if (!f.endsWith(".vue")) continue;
|
|
365
|
+
for (const m of t.matchAll(/<style[^>]*scoped[^>]*>([\s\S]*?)<\/style>/g)) {
|
|
366
|
+
for (const r of flattenCss(m[1])) {
|
|
367
|
+
const cls = [...new Set(classTokens(r.selector))];
|
|
368
|
+
if (!cls.length) continue;
|
|
369
|
+
if (/:deep\(|::v-deep|:global\(/.test(r.selector)) continue;
|
|
370
|
+
const owned = cls.filter(isOwnedClass);
|
|
371
|
+
const missing = owned.filter((c) => !markupText.includes(c));
|
|
372
|
+
const dead = missing.length > 0;
|
|
373
|
+
scopedRules.push({ ...r, file: f, dead });
|
|
374
|
+
if (dead) scopedDead.push({ sel: r.selector, file: f, missing });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 竞争者索引:只登记「与目标同一元素(同末位复合选择器)」且非挂空的规则
|
|
380
|
+
const scopedIdx = new Map();
|
|
381
|
+
for (const r of scopedRules) {
|
|
382
|
+
if (r.dead) continue; // 挂空的规则永不命中,不能算作竞争者
|
|
383
|
+
if (STATE_PSEUDO.test(r.selector)) continue; // 状态态不参与基态竞争
|
|
384
|
+
const pe = pseudoElOf(r.selector);
|
|
385
|
+
const spec = specOf(r.selector) + SCOPED_ATTR;
|
|
386
|
+
for (const cls of new Set(classTokens(lastCompound(r.selector)))) {
|
|
387
|
+
for (const prop of Object.keys(r.decls)) {
|
|
388
|
+
const key = `${cls}|${prop}|${pe}`;
|
|
389
|
+
const cur = scopedIdx.get(key);
|
|
390
|
+
if (!cur || spec > cur.spec) scopedIdx.set(key, { spec, selector: r.selector, file: r.file });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
(function checkE3() {
|
|
396
|
+
if (!scopedIdx.size) {
|
|
397
|
+
info("E3", "层叠胜负:无同属性竞争的组件 scoped 规则");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const hits = [];
|
|
401
|
+
const seen = new Set();
|
|
402
|
+
for (const r of designRules) {
|
|
403
|
+
const pe = pseudoElOf(r.selector);
|
|
404
|
+
for (const part of splitList(r.selector)) {
|
|
405
|
+
const last = lastCompound(part);
|
|
406
|
+
const clsList = [...new Set(classTokens(last))];
|
|
407
|
+
if (!clsList.length) continue;
|
|
408
|
+
const mine = specOf(part);
|
|
409
|
+
for (const prop of Object.keys(r.decls)) {
|
|
410
|
+
for (const cls of clsList) {
|
|
411
|
+
const comp = scopedIdx.get(`${cls}|${prop}|${pe}`);
|
|
412
|
+
if (!comp || mine > comp.spec) continue;
|
|
413
|
+
const key = `${part}|${prop}`;
|
|
414
|
+
if (seen.has(key)) continue;
|
|
415
|
+
seen.add(key);
|
|
416
|
+
hits.push({ sel: part, prop, cls, mine, comp });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const shown = hits.slice(0, 12);
|
|
422
|
+
for (const h of shown) {
|
|
423
|
+
const rel = path.relative(PROJECT, h.comp.file).replace(/\\/g, "/");
|
|
424
|
+
err(
|
|
425
|
+
"E3",
|
|
426
|
+
`层叠败北:${h.sel} 的 ${h.prop} 特指度 ${h.mine} ≤ 组件 scoped 竞争者 ${h.comp.selector}(${rel})的 ${h.comp.spec}(scoped 追加 [data-v] 属性选择器,等价 +1 类级)—— 该声明不会生效:收口类提权加 !important,形态类须提高锚定层级(补上 .is-active / .card-item 等对方使用的限定类)。`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
if (hits.length > shown.length) {
|
|
430
|
+
const byFile = new Map();
|
|
431
|
+
for (const h of hits.slice(shown.length)) {
|
|
432
|
+
const rel = path.relative(PROJECT, h.comp.file).replace(/\\/g, "/");
|
|
433
|
+
byFile.set(rel, (byFile.get(rel) || 0) + 1);
|
|
434
|
+
}
|
|
435
|
+
const detail = [...byFile.entries()].sort((a, b) => b[1] - a[1]).map(([f, n]) => `${f}(${n})`).join("、");
|
|
436
|
+
err("E3", `层叠败北:另有 ${hits.length - shown.length} 处未列出,集中在 ${detail} —— 同一文件成片败北说明该区域的锚定层级整体偏低,应按文件批量提权。`);
|
|
437
|
+
}
|
|
438
|
+
if (!hits.length) info("E3", `层叠胜负:0 处败北(竞争者 ${scopedIdx.size} 组)`);
|
|
439
|
+
})();
|
|
440
|
+
|
|
441
|
+
// ── E10 组件 scoped 规则挂空(重构残留) ────────────────────
|
|
442
|
+
(function checkE10() {
|
|
443
|
+
const dead = scopedDead.filter((d) => d.missing.some((c) => !IGNORE_DEAD.has(c)));
|
|
444
|
+
if (!dead.length) {
|
|
445
|
+
info("E10", "组件 scoped 挂空:0 处");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
for (const d of dead.slice(0, 15)) {
|
|
449
|
+
const rel = path.relative(PROJECT, d.file).replace(/\\/g, "/");
|
|
450
|
+
warn(
|
|
451
|
+
"E10",
|
|
452
|
+
`scoped 规则挂空:${rel} 的 ${d.sel} 依赖运行时并不存在的类 [${d.missing.join(", ")}] —— 整条规则永不命中(多在结构重构后残留),既不会生效也会误导后续收口判断。`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
if (dead.length > 15) warn("E10", `scoped 规则挂空:另有 ${dead.length - 15} 处未列出。`);
|
|
456
|
+
})();
|
|
457
|
+
|
|
458
|
+
// ── E4 选择器挂空 ───────────────────────────────────────────
|
|
459
|
+
(function checkE4() {
|
|
460
|
+
const used = new Map(); // 类名 → 声明它的设计层文件集合
|
|
461
|
+
for (const rule of designRules) {
|
|
462
|
+
for (const c of classTokens(rule.selector)) {
|
|
463
|
+
if (!OWNED_CLASS_PREFIXES.some((p) => c.startsWith(p))) continue;
|
|
464
|
+
if (!used.has(c)) used.set(c, new Set());
|
|
465
|
+
used.get(c).add(rule.file);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const phantom = [...used.keys()].filter((c) => !projectText.includes(c));
|
|
469
|
+
const hard = phantom.filter((c) => [...used.get(c)].some(isPrimary));
|
|
470
|
+
const soft = phantom.filter((c) => !hard.includes(c));
|
|
471
|
+
for (const c of hard.slice(0, 15)) {
|
|
472
|
+
err("E4", `选择器挂空:设计层选择器使用了 .${c},但 src/ 全量源码里没有该类的挂载点 —— 规则命中不到任何元素(锚点必须锚「实际在」,不能锚「应该在」)。`);
|
|
473
|
+
}
|
|
474
|
+
if (hard.length > 15) err("E4", `选择器挂空:另有 ${hard.length - 15} 个未列出。`);
|
|
475
|
+
for (const c of soft.slice(0, 10)) {
|
|
476
|
+
const files = [...used.get(c)].map((f) => path.basename(f)).join(", ");
|
|
477
|
+
warn("E4", `选择器挂空(历史层):.${c} 只出现在 ${files},项目内无挂载点 —— 静态遗留,非本次落地引入,但同样永不命中。`);
|
|
478
|
+
}
|
|
479
|
+
if (soft.length > 10) warn("E4", `选择器挂空(历史层):另有 ${soft.length - 10} 个未列出。`);
|
|
480
|
+
if (!phantom.length) info("E4", `选择器挂空:0 个(受检类名 ${used.size} 个)`);
|
|
481
|
+
})();
|
|
482
|
+
|
|
483
|
+
// ── E5 列宽预算 ─────────────────────────────────────────────
|
|
484
|
+
function extractObjectLiterals(arrSrc) {
|
|
485
|
+
const out = [];
|
|
486
|
+
let depth = 0;
|
|
487
|
+
let start = -1;
|
|
488
|
+
for (let i = 0; i < arrSrc.length; i++) {
|
|
489
|
+
const ch = arrSrc[i];
|
|
490
|
+
if (ch === "{") {
|
|
491
|
+
if (depth === 0) start = i;
|
|
492
|
+
depth++;
|
|
493
|
+
} else if (ch === "}") {
|
|
494
|
+
depth--;
|
|
495
|
+
if (depth === 0 && start >= 0) {
|
|
496
|
+
out.push(arrSrc.slice(start, i + 1));
|
|
497
|
+
start = -1;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function matchArrayLiteral(text, anchorRe) {
|
|
505
|
+
const m = anchorRe.exec(text);
|
|
506
|
+
if (!m) return null;
|
|
507
|
+
const open = text.indexOf("[", m.index);
|
|
508
|
+
if (open < 0) return null;
|
|
509
|
+
let depth = 0;
|
|
510
|
+
for (let i = open; i < text.length; i++) {
|
|
511
|
+
if (text[i] === "[") depth++;
|
|
512
|
+
else if (text[i] === "]") {
|
|
513
|
+
depth--;
|
|
514
|
+
if (depth === 0) return text.slice(open + 1, i);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
(function checkE5() {
|
|
521
|
+
const tableFiles = [...srcTextByFile.entries()].filter(([, t]) => t.includes("el-table-column"));
|
|
522
|
+
if (!tableFiles.length) {
|
|
523
|
+
info("E5", "列宽预算:未在 src 中发现 el-table-column");
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const pxOf = (s) => {
|
|
527
|
+
const m = String(s).match(/(\d+(?:\.\d+)?)\s*(px)?/);
|
|
528
|
+
return m ? Number(m[1]) : null;
|
|
529
|
+
};
|
|
530
|
+
// 表头文案最小占宽:CJK ≈ 1em,ASCII ≈ 0.6em;两侧各留 16px 宿主内边距
|
|
531
|
+
const HEADER_FS = 14;
|
|
532
|
+
const CELL_PAD = 16;
|
|
533
|
+
const textWidth = (label) => {
|
|
534
|
+
let w = 0;
|
|
535
|
+
for (const ch of String(label)) w += /[\u2e80-\u9fff\uff00-\uffef]/.test(ch) ? HEADER_FS : HEADER_FS * 0.6;
|
|
536
|
+
return Math.ceil(w);
|
|
537
|
+
};
|
|
538
|
+
const minFit = (label) => textWidth(label) + CELL_PAD * 2;
|
|
539
|
+
// 列名可能是 $t('类型') 这类 i18n 调用,需取出引号内的真实文案
|
|
540
|
+
const cleanLabel = (raw) => {
|
|
541
|
+
const s = String(raw).trim();
|
|
542
|
+
const q = s.match(/[`'"]([^`'"]+)[`'"]/);
|
|
543
|
+
return ((q ? q[1] : s).trim() || "?");
|
|
544
|
+
};
|
|
545
|
+
const labelOf = (src) => {
|
|
546
|
+
const m =
|
|
547
|
+
src.match(/label:\s*([^,\n}]+)/) || src.match(/\blabel="([^"]*)"/) || src.match(/\blabel='([^']*)'/);
|
|
548
|
+
return m ? cleanLabel(m[1]) : "?";
|
|
549
|
+
};
|
|
550
|
+
const OPS = ["operate", "operation", "actions"];
|
|
551
|
+
|
|
552
|
+
for (const [file, text] of tableFiles) {
|
|
553
|
+
const rel = path.relative(PROJECT, file).replace(/\\/g, "/");
|
|
554
|
+
const declared = [];
|
|
555
|
+
const noWidth = [];
|
|
556
|
+
|
|
557
|
+
// 1) 列定义数组(模板常用动态列)
|
|
558
|
+
const arr = matchArrayLiteral(text, /(?:const|let|var)\s+\w*[Cc]olumn\w*\s*(?::[^=]+)?=\s*\[/);
|
|
559
|
+
if (arr) {
|
|
560
|
+
for (const objSrc of extractObjectLiterals(arr)) {
|
|
561
|
+
const label = labelOf(objSrc);
|
|
562
|
+
const prop = (objSrc.match(/prop:\s*[`'"]([^`'"]+)/) || [])[1] || "?";
|
|
563
|
+
const w = objSrc.match(/\bwidth:\s*[`'"]?([\d.]+)/);
|
|
564
|
+
const mw = objSrc.match(/\bminWidth:\s*[`'"]?([\d.]+)/);
|
|
565
|
+
if (w) declared.push({ label, prop, w: pxOf(w[1]) });
|
|
566
|
+
else if (mw) declared.push({ label, prop, mw: pxOf(mw[1]) });
|
|
567
|
+
else noWidth.push({ label, prop });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 2) 内联列(selection / index / 手工列)
|
|
572
|
+
for (const tag of text.matchAll(/<el-table-column\b[\s\S]*?(?:\/>|>)/g)) {
|
|
573
|
+
const src = tag[0];
|
|
574
|
+
const type = (src.match(/:?type="['"]([^'"]+)/) || [])[1] || "";
|
|
575
|
+
const label = (src.match(/:?label="([^"]*)"/) || src.match(/:?label='([^']*)'/) || [])[1];
|
|
576
|
+
const labelText = label ? cleanLabel(label) : type || "?";
|
|
577
|
+
const hasW = /(^|\s):?width=/.test(src) || /\bwidth=/.test(src);
|
|
578
|
+
const hasMw = /:?min-width=/.test(src) || /:?minWidth=/.test(src);
|
|
579
|
+
if (hasW || hasMw) continue;
|
|
580
|
+
if (type === "selection" || type === "index") {
|
|
581
|
+
warn("E5", `${rel}:${type === "selection" ? "选择列" : "序号列"}(${labelText})未声明 width / min-width,只能按出厂兜底宽度参与布局(§11.132 要求逐列声明意图)。`);
|
|
582
|
+
} else if (type) {
|
|
583
|
+
noWidth.push({ label: labelText, prop: type });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const realNoWidth = noWidth.filter((c) => !OPS.includes(c.prop));
|
|
588
|
+
for (const c of realNoWidth) {
|
|
589
|
+
err(
|
|
590
|
+
"E5",
|
|
591
|
+
`${rel}:列「${c.label}」(prop=${c.prop}) 既无 width 也无 min-width —— 无宽列由出厂兜底宽度参与布局,会被相邻固定宽列挤压;本列表头文案需 ≥ ${minFit(c.label)}px(文案 ${textWidth(c.label)}px + 内边距 ${CELL_PAD * 2}px),请按此反推 width / min-width(换行与省略号截断都源于此)。`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// 3) 已声明 min-width 的列:min-width 必须容得下自己的表头文案
|
|
596
|
+
for (const c of declared) {
|
|
597
|
+
if (c.mw == null) continue;
|
|
598
|
+
const need = minFit(c.label);
|
|
599
|
+
if (c.mw < need) {
|
|
600
|
+
err(
|
|
601
|
+
"E5",
|
|
602
|
+
`${rel}:列「${c.label}」min-width=${c.mw}px < 表头文案最小占宽 ${need}px —— 布局算法压缩到 min-width 时表头必然换行或截断(「${c.label}」→「${c.label.slice(0, 2)}…」)。`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const total = declared.reduce((s, c) => s + (c.w ?? 0), 0);
|
|
608
|
+
const minTotal = declared.reduce((s, c) => s + (c.mw ?? 0), 0);
|
|
609
|
+
const fixedCount = declared.filter((c) => c.w).length;
|
|
610
|
+
const flexCount = declared.filter((c) => c.mw).length;
|
|
611
|
+
const budget = total + minTotal;
|
|
612
|
+
const noWidthNeed = realNoWidth.reduce((s, c) => s + minFit(c.label), 0);
|
|
613
|
+
const line = `${rel}:固定宽 ${fixedCount} 列(${total}px) / 伸缩列 ${flexCount} 列(${minTotal}px) / 无宽列 ${realNoWidth.length} 列(需 ${noWidthNeed}px)`;
|
|
614
|
+
if (CONTAINER_EXPLICIT && budget + noWidthNeed > CONTAINER_WIDTH) {
|
|
615
|
+
err(
|
|
616
|
+
"E5",
|
|
617
|
+
`${line} —— 合计 ${budget + noWidthNeed}px > 容器可用宽 ${CONTAINER_WIDTH}px:列宽预算溢出,必须在设计侧收敛(减少列 / 降 min-width / 显式声明横向滚动),不允许靠静默压缩消化。`,
|
|
618
|
+
);
|
|
619
|
+
} else if (!CONTAINER_EXPLICIT && budget + noWidthNeed > CONTAINER_WIDTH) {
|
|
620
|
+
warn(
|
|
621
|
+
"E5",
|
|
622
|
+
`${line} —— 合计 ${budget + noWidthNeed}px 超出假定容器宽 ${CONTAINER_WIDTH}px。容器宽为估值,请用 --container-width <px> 传入实际内容区宽度后复跑,据以判定是否溢出。`,
|
|
623
|
+
);
|
|
624
|
+
} else {
|
|
625
|
+
info("E5", `${line},合计 ${budget + noWidthNeed}px ≤ ${CONTAINER_WIDTH}px`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
})();
|
|
629
|
+
|
|
630
|
+
// ── E6 visible:false 收口断言 ──────────────────────────────
|
|
631
|
+
(function checkE6() {
|
|
632
|
+
if (!changeset) {
|
|
633
|
+
info("E6", "未发现变更集(.codebuddy/design/changeset*.json),跳过收口断言");
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const changes = changeset.changes || [];
|
|
637
|
+
const closable = changes.filter((c) => c.visible === false || c.style?.visible === false);
|
|
638
|
+
if (!closable.length) {
|
|
639
|
+
info("E6", "收口断言:变更集无 visible:false 项");
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
for (const change of closable) {
|
|
643
|
+
const entry = dictSidMap.get(change.id);
|
|
644
|
+
const selector = entry?.selector;
|
|
645
|
+
if (!selector) {
|
|
646
|
+
warn("E6", `${change.id} 声明 visible:false,但词典无 selector,无法断言收口落点。`);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
const target = classTokens(selector).slice(-1)[0];
|
|
650
|
+
if (!target) {
|
|
651
|
+
warn("E6", `${change.id} 的词典 selector「${selector}」不含类名,无法断言收口落点。`);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const rule = designRules.find(
|
|
655
|
+
(r) => r.selector.includes(`.${target}`) && /none/i.test(r.decls.display || ""),
|
|
656
|
+
);
|
|
657
|
+
if (!rule) {
|
|
658
|
+
err("E6", `${change.id} 声明 visible:false,但设计层没有任何「命中 .${target} 且 display:none」的收口规则 —— 元素会照旧显示。`);
|
|
659
|
+
} else if (!/!important/i.test(rule.decls.display)) {
|
|
660
|
+
warn("E6", `${change.id} 的收口规则 ${rule.selector} 的 display:none 未加 !important,存在被组件 scoped 同权重反压的风险。`);
|
|
661
|
+
} else {
|
|
662
|
+
info("E6", `${change.id} → ${rule.selector} display:none !important ✓`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
})();
|
|
666
|
+
|
|
667
|
+
// ── E7 激活态越层 ───────────────────────────────────────────
|
|
668
|
+
(function checkE7() {
|
|
669
|
+
const hits = designRules.filter((r) => {
|
|
670
|
+
const sel = r.selector;
|
|
671
|
+
const onGroup = /el-sub-menu|__title/.test(sel);
|
|
672
|
+
const active = /is-active|\.active\b|\[aria-current|\.is-opened/.test(sel);
|
|
673
|
+
if (!onGroup || !active) return false; // 必须同时命中「分组标题」与「激活态」才是越层
|
|
674
|
+
const setsSurface = Object.keys(r.decls).some(
|
|
675
|
+
(p) => p === "background" || p.startsWith("background") || p === "box-shadow" || p === "border-inline-start",
|
|
676
|
+
);
|
|
677
|
+
const pseudoBar = /::?(before|after)/.test(sel);
|
|
678
|
+
return setsSurface || pseudoBar;
|
|
679
|
+
});
|
|
680
|
+
for (const r of hits.slice(0, 10)) {
|
|
681
|
+
err("E7", `激活态越层:${r.selector} 把激活态(胶囊/指示条)写到了可展开分组的标题上 —— 分组标题会同时长出胶囊与竖向指示条,并与右侧展开箭头叠压(§6.15/§6.16 要求激活态只作用叶子项,分组标题须以 :not(.el-sub-menu__title) 排除)。`);
|
|
682
|
+
}
|
|
683
|
+
if (!hits.length) info("E7", "激活态越层:0 处");
|
|
684
|
+
})();
|
|
685
|
+
|
|
686
|
+
// ── E8 浮层残留 ─────────────────────────────────────────────
|
|
687
|
+
(function checkE8() {
|
|
688
|
+
if (!changeset) {
|
|
689
|
+
info("E8", "未发现变更集,跳过浮层残留检查");
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
for (const change of changeset.changes || []) {
|
|
693
|
+
const form = change.form;
|
|
694
|
+
if (!form) continue;
|
|
695
|
+
const entry = dictSidMap.get(change.id);
|
|
696
|
+
if (!entry) continue;
|
|
697
|
+
const required = entry.suppress_rules?.[form] || [];
|
|
698
|
+
const structural = [...required, ...(entry.structure_ops_required?.[form] || [])].join(" ");
|
|
699
|
+
if (!/popper|dropdown|浮层/i.test(structural)) continue;
|
|
700
|
+
const compFile = entry.file && typeof entry.file === "string" ? entry.file.match(/(src\/[\w./-]+\.vue)/) : null;
|
|
701
|
+
if (!compFile) continue;
|
|
702
|
+
const p = path.join(PROJECT, compFile[1]);
|
|
703
|
+
const t = readIf(p);
|
|
704
|
+
if (!t) continue;
|
|
705
|
+
if (/#dropdown|ElDropdown|el-dropdown/.test(t)) {
|
|
706
|
+
err("E8", `${change.id} 的 form=${form} 要求收口下拉浮层,但 ${compFile[1]} 仍保留 ElDropdown / #dropdown 消费 —— 点击触发器仍会弹出浮层(浮层属 teleport popper,样式层收不掉)。`);
|
|
707
|
+
} else {
|
|
708
|
+
info("E8", `${change.id} 浮层收口 ✓`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
})();
|
|
712
|
+
|
|
713
|
+
// ── E9 形态收口缺件 ─────────────────────────────────────────
|
|
714
|
+
// 形态类 form(如 icon_only)落地必须「成对」:显图标 + 隐文案,且收口要落回该语义元素
|
|
715
|
+
// 自己的作用域内。只按「全局有没有 .tag-title{display:none}」判断会被兄弟变体规则骗过。
|
|
716
|
+
const normSel = (s) => String(s).replace(/\s+/g, " ").trim();
|
|
717
|
+
const lastClassOutsideNot = (sel) => {
|
|
718
|
+
const cleaned = String(sel).replace(/:not\([^)]*\)/g, "");
|
|
719
|
+
const parts = splitList(cleaned);
|
|
720
|
+
const last = parts.length ? lastCompound(parts[parts.length - 1]) : "";
|
|
721
|
+
const cls = classTokens(last);
|
|
722
|
+
if (cls.length) return cls[cls.length - 1];
|
|
723
|
+
const all = classTokens(cleaned);
|
|
724
|
+
return all.length ? all[all.length - 1] : null;
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
(function checkE9() {
|
|
728
|
+
if (!changeset) {
|
|
729
|
+
info("E9", "未发现变更集,跳过形态收口检查");
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
for (const change of changeset.changes || []) {
|
|
733
|
+
if (!/^(icon_only|icon)$/.test(change.form || "")) continue;
|
|
734
|
+
const entry = dictSidMap.get(change.id);
|
|
735
|
+
if (!entry) {
|
|
736
|
+
warn("E9", `${change.id} 的 form=${change.form} 在词典里找不到条目,无法断言形态收口。`);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
const scope = normSel(entry.selector || "");
|
|
740
|
+
const subs = entry.sub_selectors || {};
|
|
741
|
+
const textSub = subs.title || subs.text;
|
|
742
|
+
const iconSub = subs.icon || subs.leading_icon;
|
|
743
|
+
if (!scope || !textSub) {
|
|
744
|
+
warn("E9", `${change.id} 的词典缺少 selector 或 sub_selectors.title,无法断言「显图标 + 隐文案」是否成对。`);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const textTarget = lastClassOutsideNot(textSub);
|
|
748
|
+
const iconTarget = iconSub ? lastClassOutsideNot(iconSub) : null;
|
|
749
|
+
const inScope = (r) => {
|
|
750
|
+
const s = normSel(r.selector);
|
|
751
|
+
return s.includes(scope);
|
|
752
|
+
};
|
|
753
|
+
const closed = designRules.filter(
|
|
754
|
+
(r) => inScope(r) && /none/i.test(r.decls.display || "") && (!textTarget || normSel(r.selector).includes(`.${textTarget}`)),
|
|
755
|
+
);
|
|
756
|
+
const shown =
|
|
757
|
+
(iconTarget &&
|
|
758
|
+
designRules.find((r) => inScope(r) && normSel(r.selector).includes(`.${iconTarget}`) && /(block|inline-flex|flex|inline-block)/i.test(r.decls.display || ""))) ||
|
|
759
|
+
null;
|
|
760
|
+
const closedIcon = designRules.find((r) => inScope(r) && /\.card-tab-icon|\.tag-icon|icon/i.test(normSel(r.selector)) && /none/i.test(r.decls.display || ""));
|
|
761
|
+
// 图标与文案至少要有一边在本作用域内被显式声明,否则说明该形态未在本层落地
|
|
762
|
+
const landed = shown || closedIcon || closed.length;
|
|
763
|
+
if (!landed) continue;
|
|
764
|
+
if (!closed.length) {
|
|
765
|
+
err(
|
|
766
|
+
"E9",
|
|
767
|
+
`${change.id} 的 form=${change.form} 只落了「显图标」,没有在同一作用域 ${scope} 内收口文案(需要 .${textTarget} 的 display:none,锚定 ${scope})—— 图标与文案并存会把固定窄宽挤爆,文案被截断(如「工作台」→「工…」)。注意:作用域外的兄弟变体收口(如 :not(:nth-child(1)))不算成对。`,
|
|
768
|
+
);
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (!/!important/i.test(closed[0].decls.display || "")) {
|
|
772
|
+
warn("E9", `${change.id} 的文案收口 ${closed[0].selector} 未加 !important,存在被组件 scoped 同权反压的风险。`);
|
|
773
|
+
} else {
|
|
774
|
+
info("E9", `${change.id} 图标/文案收口成对 ✓(${closed[0].selector})`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
})();
|
|
778
|
+
|
|
779
|
+
// ── @sid 标记覆盖率(可选增强) ─────────────────────────────
|
|
780
|
+
(function checkSidCoverage() {
|
|
781
|
+
const marked = new Set([...designCss.matchAll(/@sid\s*:\s*([\w.$-]+)/g)].map((m) => m[1]));
|
|
782
|
+
if (!marked.size) {
|
|
783
|
+
info("SID", "设计层未使用 @sid 标记;建议每条设计层规则上方标注 /* @sid: <语义ID> */,即可启用「变更集 ↔ 设计层」逐条覆盖对账。");
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const declared = new Set((changeset?.changes || []).map((c) => c.id));
|
|
787
|
+
const missing = [...declared].filter((sid) => !marked.has(sid));
|
|
788
|
+
for (const sid of missing) {
|
|
789
|
+
warn("SID", `变更集声明了 ${sid},但设计层没有对应 @sid 标记 —— 无法证明该条已落到样式/结构。`);
|
|
790
|
+
}
|
|
791
|
+
info("SID", `@sid 覆盖率:${declared.size - missing.length}/${declared.size}`);
|
|
792
|
+
})();
|
|
793
|
+
|
|
794
|
+
// ── 输出 ────────────────────────────────────────────────────
|
|
795
|
+
const result = { project: PROJECT, errors, warnings, infos };
|
|
796
|
+
|
|
797
|
+
const CHECK_TITLES = {
|
|
798
|
+
E1: "变量悬空",
|
|
799
|
+
E2: "层错位",
|
|
800
|
+
E3: "层叠败北",
|
|
801
|
+
E4: "选择器挂空",
|
|
802
|
+
E5: "列宽预算",
|
|
803
|
+
E6: "visible:false 收口断言",
|
|
804
|
+
E7: "激活态越层",
|
|
805
|
+
E8: "浮层残留",
|
|
806
|
+
E9: "形态收口缺件",
|
|
807
|
+
E10: "scoped 规则挂空",
|
|
808
|
+
SID: "@sid 覆盖率",
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
const REPORT_PATH = argValue("--report", null);
|
|
812
|
+
if (REPORT_PATH) {
|
|
813
|
+
const rel = (p) => path.relative(PROJECT, p).replace(/\\/g, "/");
|
|
814
|
+
const rows = Object.keys(CHECK_TITLES)
|
|
815
|
+
.map((id) => {
|
|
816
|
+
const e = errors.filter((x) => x.id === id).length;
|
|
817
|
+
const w = warnings.filter((x) => x.id === id).length;
|
|
818
|
+
return { id, title: CHECK_TITLES[id], e, w };
|
|
819
|
+
})
|
|
820
|
+
.filter((r) => r.e || r.w);
|
|
821
|
+
|
|
822
|
+
const lines = [
|
|
823
|
+
"# 设计层闭环审计报告",
|
|
824
|
+
"",
|
|
825
|
+
`- 项目:\`${PROJECT}\``,
|
|
826
|
+
`- 设计层文件:${DESIGN_FILES.map(rel).join("、") || "(未发现)"}`,
|
|
827
|
+
`- 变更集:${changesetFile ? rel(changesetFile) : "(未发现)"}`,
|
|
828
|
+
`- 词典条目:${dictEntries.length} | 设计层规则:${designRules.length} | 容器可用宽:${CONTAINER_WIDTH}px${CONTAINER_EXPLICIT ? "" : "(估值,用 --container-width 校准)"}`,
|
|
829
|
+
`- 结论:**ERROR ${errors.length} | WARN ${warnings.length}**`,
|
|
830
|
+
"",
|
|
831
|
+
"## 命中分布",
|
|
832
|
+
"",
|
|
833
|
+
"| 检查项 | 含义 | ERROR | WARN |",
|
|
834
|
+
"|---|---|---|---|",
|
|
835
|
+
...rows.map((r) => `| ${r.id} | ${r.title} | ${r.e} | ${r.w} |`),
|
|
836
|
+
"",
|
|
837
|
+
"## 待修项(ERROR)",
|
|
838
|
+
"",
|
|
839
|
+
...(errors.length ? errors.map((e, i) => `${i + 1}. **[${e.id}] ${CHECK_TITLES[e.id] || ""}** — ${e.msg}`) : ["(无)"]),
|
|
840
|
+
"",
|
|
841
|
+
"## 提示项(WARN)",
|
|
842
|
+
"",
|
|
843
|
+
...(warnings.length ? warnings.map((w, i) => `${i + 1}. **[${w.id}] ${CHECK_TITLES[w.id] || ""}** — ${w.msg}`) : ["(无)"]),
|
|
844
|
+
"",
|
|
845
|
+
"## 已通过项",
|
|
846
|
+
"",
|
|
847
|
+
...(infos.length ? infos.map((i) => `- [${i.id}] ${i.msg}`) : ["(无)"]),
|
|
848
|
+
"",
|
|
849
|
+
`> 复跑:\`node scripts/design-audit.mjs --project "${PROJECT}" --container-width ${CONTAINER_WIDTH}\``,
|
|
850
|
+
"",
|
|
851
|
+
];
|
|
852
|
+
const out = path.resolve(REPORT_PATH);
|
|
853
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
854
|
+
fs.writeFileSync(out, lines.join("\n"), "utf-8");
|
|
855
|
+
console.log(`\n审计报告已写入:${out}`);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (AS_JSON) {
|
|
859
|
+
console.log(JSON.stringify(result, null, 2));
|
|
860
|
+
} else {
|
|
861
|
+
console.log("\n设计层闭环审计\n");
|
|
862
|
+
console.log(`项目: ${PROJECT}`);
|
|
863
|
+
console.log(`设计层文件: ${DESIGN_FILES.map((f) => path.relative(PROJECT, f).replace(/\\/g, "/")).join(", ") || "(未发现)"}`);
|
|
864
|
+
console.log(`变更集: ${changesetFile ? path.relative(PROJECT, changesetFile) : "(未发现)"}`);
|
|
865
|
+
console.log(`词典条目: ${dictEntries.length} | 设计层规则: ${designRules.length}\n`);
|
|
866
|
+
for (const i of infos) console.log(` · [${i.id}] ${i.msg}`);
|
|
867
|
+
for (const w of warnings) console.log(` ! [${w.id}] ${w.msg}`);
|
|
868
|
+
for (const e of errors) console.log(` \u2717 [${e.id}] ${e.msg}`);
|
|
869
|
+
console.log(`\n${"=".repeat(50)}`);
|
|
870
|
+
console.log(`闭环审计:ERROR ${errors.length} | WARN ${warnings.length}`);
|
|
871
|
+
if (errors.length) {
|
|
872
|
+
console.log("\n\u2717 设计层存在「声明了却不会生效 / 形态未落地」的问题,修复后重跑。\n");
|
|
873
|
+
} else {
|
|
874
|
+
console.log("\n\u2713 设计层闭环审计通过(样式声明均已具备生效条件)。\n");
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
process.exit(errors.length ? 1 : 0);
|