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

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/README.md CHANGED
@@ -23,16 +23,31 @@ Vue 3 企业级中后台项目开发技能包。两条链路:
23
23
 
24
24
  ```text
25
25
  输入:<项目根>/.design/visual-contract-<场景>-<页型>.json
26
- → 读契约(唯一输入,只读一次)
27
- → 选 adapter(按 package.json 判定所用组件库)
28
- → 生成 ui-tokens.css(:root 变量)
29
- → 生成 ui-roles.css(.role-* 规则)
30
- → 生成 ui-layout.css(.ui-page 网格)
31
- → 挂载 + 目视对照
26
+ → ① 落地自检(六项:契约存在/完整/CSS生成/CSS引入/角色挂载/布局挂载)
27
+ → ② 读契约(唯一输入,只读一次)
28
+ → ③ 选 adapter(按 package.json 判定所用组件库)
29
+ → ④ 生成 ui-tokens.css(:root 变量)
30
+ → ⑤ 生成 ui-roles.css(.role-* 规则)
31
+ → ⑥ 生成 ui-layout.css(.ui-page 网格)
32
+ → ⑦ 挂载(.ui-page / .ui-region-* / .role-*)
33
+ → ⑧ 重跑自检确认六项全过 → 目视对照
32
34
  ```
33
35
 
34
36
  **AI 在链路二的工作是翻译,不是判断。** 契约写什么就生成什么——落点层级已由 `host[]` / `inner[]` 两个数组物理二分,落地端不推断属性该落哪一层。
35
37
 
38
+ **落地自检是交付前置条件**(`npm run check:landing`):
39
+
40
+ | 检查项 | 断点含义 |
41
+ |---|---|
42
+ | `contract-found` | `.design/` 下没有契约 → 设计侧没落盘 |
43
+ | `contract-valid` | 契约字段缺失 |
44
+ | `css-generated` | 三份 CSS 未生成 |
45
+ | `css-imported` | **生成了但没被 import** → 浏览器不加载 |
46
+ | `roles-mounted` | **CSS 有规则但模板没挂类** → 死样式,只有个别角色生效 |
47
+ | `layout-mounted` | 没挂 `.ui-page` → 骨架仍是项目原样 |
48
+
49
+ **任何一项断掉都会表现为「CSS 写了但页面没变化」。先跑自检定位断点,不要目视猜。**
50
+
36
51
  ## 视觉契约的核心机制
37
52
 
38
53
  | 机制 | 说明 |
@@ -84,6 +99,7 @@ npx @qilitt-mickey/vue3-temp-skill@latest update
84
99
  | 命令 | 职责 |
85
100
  |------|------|
86
101
  | `node bin/cli.js install <target>` | 安装器统一入口:`install` / `update` / `uninstall` / `list` |
102
+ | `npm run check:landing` | **契约落地自检**(六项):判断「契约 → 样式 → 挂载」断在哪一环 |
87
103
  | `python scripts/match.py "需求" --json` | 能力路由:按需求返回应加载的 reference 清单 |
88
104
  | `npm run release`(`:minor` / `:major`) | 结构检查 → 升版本 → 提交 → 发布 npm |
89
105
 
package/SKILL.md CHANGED
@@ -30,7 +30,14 @@ tags: [vue3, typescript, element-plus, pinia, vite, unocss, crud, demo, code-qua
30
30
 
31
31
  1. **项目探查**:读取目标项目的 `package.json`、路由、页面、`Re*` 组件、Hooks、API 和样式;以源码事实为准。
32
32
  2. **能力路由**:运行 `scripts/match.py "需求" --json`,**仅**按输出中的 `load_files` 阅读 reference;禁止自行展开全部 `references/`。无 Python 时按下方能力组表手动匹配。
33
- 3. **视觉契约落地**:输入是设计侧的视觉契约 JSON 时,走 `design-apply` 协议——照抄生成三份样式文件(令牌 / 角色 / 布局),见 §3 输入分流。
33
+ 3. **视觉契约落地**:输入是设计侧的视觉契约 JSON 时,走 `design-apply` 协议——**先跑落地自检,再照抄生成三份样式文件**,见 §3 输入分流。
34
+
35
+ ```bash
36
+ node <技能>/scripts/check-landing.mjs --project <项目根>
37
+ ```
38
+
39
+ **六项全过(exit 0)是交付前置条件**:契约存在 → 契约完整 → 三份 CSS 生成 → 被 import → 角色类挂载 100% → 布局类挂载。
40
+ **任何一项断掉,都会出现「CSS 写了但页面没变化」——先修断点,不要目视猜。**
34
41
 
35
42
  ## 能力组路由
36
43
 
@@ -49,7 +56,7 @@ tags: [vue3, typescript, element-plus, pinia, vite, unocss, crud, demo, code-qua
49
56
  | `files-and-editors` | 附件、预览、下载、富文本、二维码 | `file-management.md`、`download-export.md`、`rich-text.md`、`qrcode-barcode.md` |
50
57
  | `realtime-and-mobile` | WebSocket、移动端、微信、验证码、动效 | 对应专项 reference |
51
58
  | `data-compare` | 编辑页变更对比和汇总 | `data-compare.md` |
52
- | `design-apply` | **视觉契约落地**:读契约 → 选 adapter → 生成三份 CSS → 目视对照。**主入口 `design-apply.md`,adapter 见 `adapters/`(做到哪一步读哪一份)** | `design-apply.md`、`adapters/`、`mapping/` |
59
+ | `design-apply` | **视觉契约落地**:落地自检 → 读契约 → 选 adapter → 生成三份 CSS → 挂载 → 覆盖验证。**主入口 `design-apply.md`,adapter 见 `adapters/`(做到哪一步读哪一份)** | `design-apply.md`、`adapters/`、`mapping/`、`scripts/check-landing.mjs` |
53
60
 
54
61
  ## 工作流
55
62
 
@@ -86,10 +93,12 @@ tags: [vue3, typescript, element-plus, pinia, vite, unocss, crud, demo, code-qua
86
93
 
87
94
  使用者默认**不懂设计也不懂代码**,交互遵守四条:
88
95
 
89
- 1. **全自动默认**:识别为视觉契约后**直接开始执行全流程**(读契约 → 选 adapter → 生成三份 CSS → 挂载 → 目视对照),不等用户逐步确认。唯一停顿点是契约文件缺失或格式不符——此时用大白话报告并给选择题。
90
- 2. **术语不对用户出现**:对用户不说"契约 / 角色 / 令牌 / adapter / 区域"——说"设计文件 / 样式规则 / 全局变量 / 组件适配 / 页面区域"。这些词只出现在内部文档与交付说明里。
91
- 3. **无文件时的引导**:用户说"全系统改成 XX 风格"但没给设计文件 → 提示一句话:"请先在设计助手(project-ui-design 技能)里说『我要 XX 风格的全系统设计』拿到设计文件,再回来发给我"。如果本机同时装有设计技能,可直接引导用户回到设计会话完成设计,再回本项目落地。
92
- 4. **交付话术**:完成后用大白话总结(改了哪些页面 / 哪些地方变了 / 怎么验收),不输出施工坐标等技术细节;验收引导统一为"运行 `pnpm dev` 打开页面对照看效果,哪里不满意直接说"。
96
+ 1. **全自动默认**:识别为视觉契约后**直接开始执行全流程**(落地自检 → 读契约 → 选 adapter → 生成三份 CSS → 挂载 → 验证),不等用户逐步确认。
97
+ 2. **契约缺失 = 硬阻断,不允许"边猜边改"**:`check-landing.mjs` 报 `contract-found FAIL` 时**必须停下**,用大白话报告「还没拿到设计文件」并给出下一步。**严禁在契约缺失的情况下凭理解直接改项目样式**——那是旧体系的失败模式:改了一堆 CSS 却没有任何东西对应,最后只有个别地方碰巧变了。
98
+ 3. **契约存在但 `roles-mounted` 未 100% = 不算完成**:角色类没挂满,页面上就只会有个别角色生效(常见症状:只有菜单颜色变了)。必须按 adapter 的「角色类挂载方式」补齐后重跑自检。
99
+ 4. **术语不对用户出现**:对用户不说"契约 / 角色 / 令牌 / adapter / 区域 / 自检"——说"设计文件 / 样式规则 / 全局变量 / 组件适配 / 页面区域 / 检查"。这些词只出现在内部文档与交付说明里。
100
+ 5. **无文件时的引导**:用户说"全系统改成 XX 风格"但没给设计文件 → 提示一句话:"请先在设计助手(project-ui-design 技能)里说『我要 XX 风格的全系统设计』拿到设计文件,再回来发给我"。如果本机同时装有设计技能,可直接引导用户回到设计会话完成设计,再回本项目落地。
101
+ 6. **交付话术**:完成后用大白话总结(改了哪些页面 / 哪些地方变了 / 怎么验收),不输出施工坐标等技术细节;验收引导统一为"运行 `pnpm dev` 打开页面对照看效果,哪里不满意直接说"。
93
102
 
94
103
  ### 4. 验证与交付
95
104
 
package/bin/cli.js CHANGED
@@ -35,7 +35,7 @@ const TRACK_FILE = path.join(HOME, ".vue3-temp-skill.json");
35
35
  // 累积性(ledger 台账)/ 终态机读凭证(四闸串联,FINAL-GATE: PASS)是落地会话的运行时闸——
36
36
  // 缺了它们「只挑主题令牌落地 + 覆盖历史成果 + 以自述结束会话」无法被静态拦截
37
37
  // (失效模式:运行时副本缺审计闸时,残缺口变更集会一路绿灯)。
38
- const RUNTIME_SCRIPT_FILES = ["match.py", "modules.json"];
38
+ const RUNTIME_SCRIPT_FILES = ["match.py", "modules.json", "check-landing.mjs"];
39
39
  const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(SKILL_DIR, "package.json"), "utf-8")).version;
40
40
 
41
41
  // ============================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qilitt-mickey/vue3-temp-skill",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "type": "module",
5
5
  "description": "Vue 3 企业级中后台项目开发规范技能包 — core-kernel 架构、按需功能模块、视觉契约落地",
6
6
  "bin": {
@@ -25,6 +25,7 @@
25
25
  "release": "node release.js",
26
26
  "release:minor": "node release.js minor",
27
27
  "release:major": "node release.js major",
28
+ "check:landing": "node scripts/check-landing.mjs --project .",
28
29
  "match": "python scripts/match.py",
29
30
  "match:batch": "python scripts/match.py --batch",
30
31
  "postpublish": "echo 'Published @qilitt-mickey/vue3-temp-skill to npmjs.org'"
@@ -44,6 +44,46 @@ description: Vue 3 + Ant Design Vue 适配。提供深度选择器语法、组
44
44
  | DatePicker | `.ant-picker` | `.ant-picker-input input` |
45
45
  | Form | `.ant-form` | `.ant-form-item` |
46
46
 
47
+ ## 角色类挂载方式(视觉不生效的头号原因)
48
+
49
+ **CSS 生成了不等于视觉会变**——必须把角色类挂到组件上。
50
+
51
+ | 契约角色 | 挂载写法 |
52
+ |---|---|
53
+ | `button.*` | `<a-button :class="[...]">` |
54
+ | `shell.sidebar.item` | `<a-menu-item :class="[...]">` |
55
+ | `shell.tabs.item` | `<a-tab-pane :class="[...]">` |
56
+ | `input.base` / `filter.input` | `<a-input :class="[...]">`,`inner` 用 `:deep(.ant-input)` |
57
+ | `select.base` | `<a-select :class="[...]">`,`inner` 用 `:deep(.ant-select-selector)` |
58
+ | `filter.dateRange` | `<a-range-picker :class="[...]">` |
59
+ | `tag.status` | `<a-tag :class="[...]">` |
60
+ | `card.base` | `<a-card :class="[...]">`,`inner` 用 `:deep(.ant-card-body)` |
61
+ | `table.wrapper` | `<a-table :class="[...]">` |
62
+ | `table.headerCell` / `table.cell` | **用 `#headerCell` / `#bodyCell` 插槽**:`#bodyCell="{ column, record }"` 内给单元格外层元素加类 |
63
+ | `table.cell.amount` | 同上 + `align: 'right'` |
64
+ | `pagination.bar` | `<a-pagination :class="[...]">` |
65
+
66
+ **Table 的特殊性**:`a-table` 的单元格同样由组件内部生成。推荐用 `#bodyCell` 插槽批量挂载:
67
+
68
+ ```vue
69
+ <a-table :data-source="rows" :columns="columns" class="role-table-wrapper">
70
+ <template #headerCell="{ column }">
71
+ <div :class="column.dataIndex === 'name' ? 'role-table-header-cell' : 'role-table-header-cell'">
72
+ {{ column.title }}
73
+ </div>
74
+ </template>
75
+ <template #bodyCell="{ column, record }">
76
+ <div :class="cellRole(column, record)">{{ record[column.dataIndex] }}</div>
77
+ </template>
78
+ </a-table>
79
+ ```
80
+
81
+ ### 批量挂载的做法
82
+
83
+ 一个页面有 20+ 角色时**逐个手写 class 会漏**。做法:把契约角色名转成类名清单,交给页面统一挂载,并在第 0 步自检里确认覆盖率 100%。
84
+
85
+ **类名转换规则**:`table.headerCell` → `.role-table-header-cell`(点号与驼峰都转小写连字符)。
86
+
47
87
  ## 提权通道(唯一允许处)
48
88
 
49
89
  | 情况 | 处置 |
@@ -47,6 +47,48 @@ description: Vue 3 + Element Plus 适配。提供深度选择器语法、组件
47
47
  | Tabs | `.el-tabs` | `.el-tabs__item` |
48
48
  | DatePicker | `.el-date-editor` | `.el-range-input` |
49
49
 
50
+ ## 角色类挂载方式(视觉不生效的头号原因)
51
+
52
+ **CSS 生成了不等于视觉会变**——必须把角色类挂到组件上。Element Plus 各组件的挂法不同:
53
+
54
+ | 契约角色 | 挂载写法 | 说明 |
55
+ |---|---|---|
56
+ | `shell.sidebar.item` / `.active` | `<el-menu-item :class="[...]">` | 直接作用在组件根元素 |
57
+ | `shell.header.icon` | `<el-button :class="[...]">` | 同上 |
58
+ | `button.primary` / `.secondary` / `.danger` | `<el-button :class="[...]">` | 同上 |
59
+ | `card.base` | `<el-card :class="[...]">` | 内边距在 `.el-card__body`,`host` 作用在根、`inner` 用 `:deep(.el-card__body)` |
60
+ | `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` |
64
+ | `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)` |
67
+ | `filter.dateRange` | `<el-date-picker :class="[...]">` | — |
68
+ | `pagination.bar` | `<el-pagination :class="[...]">` | 直接作用 |
69
+ | `shell.tabs.item` | 自绘或 `<el-tab-pane>` | 激活态另由 `item.active` 角色承载 |
70
+
71
+ **Table 的特殊性**:`el-table` 的 `th` / `td` 由组件内部生成,**不能直接在 `<el-table>` 上加类来影响单元格**。表头必须用 `#header` 插槽自绘:
72
+
73
+ ```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>
83
+ </el-table>
84
+ ```
85
+
86
+ ### 批量挂载的做法
87
+
88
+ 一个页面有 20+ 角色时**逐个手写 class 会漏**。做法:把契约的角色名转成类名清单,交给页面统一挂载,并在第 0 步自检里确认覆盖率 100%。
89
+
90
+ **类名转换规则**:`table.headerCell` → `.role-table-header-cell`(点号与驼峰都转小写连字符)。
91
+
50
92
  ## 提权通道(唯一允许处)
51
93
 
52
94
  角色类是独立命名空间(`role-` 前缀)+ 独立文件,**通常不需要提权**。仅两种情况需要:
@@ -38,6 +38,25 @@ src/styles/ui-layout.css 区域网格
38
38
 
39
39
  ## 执行流程(六步)
40
40
 
41
+ ### 第 0 步 · 落地自检(先跑,断在哪一目了然)
42
+
43
+ ```bash
44
+ node <技能>/scripts/check-landing.mjs --project <项目根>
45
+ ```
46
+
47
+ **六项检查,逐项给修复动作,exit 1 表示链路有断点:**
48
+
49
+ | # | 检查项 | 断点含义 |
50
+ |---|---|---|
51
+ | 1 | `contract-found` | `.design/visual-contract-*.json` 不存在 → 设计侧没落盘 |
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` → 骨架仍是项目原样 |
57
+
58
+ **六项全过是交付前置条件。** 不过就修断点,**不要直接目视猜问题在哪**。
59
+
41
60
  ### 第 1 步 · 读契约(只读一次)
42
61
 
43
62
  读取 `<项目根>/.design/visual-contract-*.json`。多份契约(多页型)逐份独立施工。
@@ -190,17 +209,19 @@ src/styles/ui-layout.css 区域网格
190
209
 
191
210
  **实现方式二选一**:能挂 class 就挂 class(简单可靠);挂不上就用组件的 `row-class-name` 回调。**由 adapter 决定,不由落地端临时发明。**
192
211
 
193
- ### 第 6 步 · 挂载 + 目视对照
212
+ ### 第 6 步 · 挂载 + 覆盖验证
194
213
 
195
214
  1. 在样式入口引入三份文件(一次性,入口位置按项目现状)
196
215
  2. 页面根节点挂 `.ui-page`,各区域挂 `.ui-region-<名>`
197
- 3. 区域内组件挂对应 `.role-<名>` 类
198
- 4. 按契约 `pages.<页型>.nodes` 把组件填进对应区域
199
-
200
- **然后目视对照**:打开页面与契约 `meta.source.mockup` 指向的效果图并排看。
216
+ 3. 按 adapter 的「角色类挂载方式」给组件挂 `.role-*`(**表格走插槽,不是在 `<el-table>` 上加类**)
217
+ 4. **重跑第 0 步的落地自检,六项全过才算挂载完成**
218
+ 5. 目视对照:打开页面与契约 `meta.source.mockup` 指向的效果图并排看
201
219
 
202
220
  | 现象 | 根因 | 修正动作 |
203
221
  |---|---|---|
222
+ | 页面完全没变化 | 三份 CSS 没被 import | 在样式入口 import 一次 |
223
+ | 只有个别地方变了 | 角色类挂载覆盖率低 | 按 adapter 挂法补齐,重跑自检 |
224
+ | 布局还是项目原样 | 没挂 `.ui-page` / `.ui-region-*` | 布局组件加类 |
204
225
  | 有差异 | 契约值或落点层不对 | **改契约 → 重新生成 CSS** |
205
226
  | 颜色不对 | 令牌值抄错 | 改 `tokens` → 重新生成 |
206
227
  | 字号 / 字重不对 | 落错层(该进 `inner[]` 进了 `host[]`) | 改契约的数组归属 → 重新生成 |
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 契约落地自检 —— 判断「契约 → 样式 → 挂载」三个闭环在哪一环断了
5
+ *
6
+ * 用法:
7
+ * node check-landing.mjs --project <项目根目录>
8
+ * node check-landing.mjs --project . --json
9
+ *
10
+ * 退出码: 0 = 六项全过 / 1 = 有 FAIL 项
11
+ *
12
+ * 设计原则: 纯读取 + 确定性判断。不猜测、不修改、不联网。
13
+ */
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ const IGNORE_DIRS = new Set([
19
+ "node_modules", "dist", ".git", ".vite", "coverage",
20
+ "__pycache__", ".idea", ".vscode", ".workbuddy", "unpackage",
21
+ ]);
22
+
23
+ const CSS_FILES = ["ui-tokens.css", "ui-roles.css", "ui-layout.css"];
24
+ const SOURCE_EXT = new Set([".vue", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
25
+ const STYLE_EXT = new Set([".css", ".scss", ".less"]);
26
+
27
+ const EXIT_OK = 0;
28
+ const EXIT_FAIL = 1;
29
+
30
+ // ────────────────────────────── 参数解析
31
+
32
+ function parseArgs(argv) {
33
+ const out = { project: process.cwd(), json: false };
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i];
36
+ if (a === "--project" || a === "-p") {
37
+ const v = argv[i + 1];
38
+ if (!v || v.startsWith("--")) {
39
+ console.error("错误: --project 后面必须跟项目根目录路径");
40
+ process.exit(EXIT_FAIL);
41
+ }
42
+ out.project = path.resolve(v);
43
+ i++;
44
+ } else if (a === "--json") {
45
+ out.json = true;
46
+ } else if (a === "--help" || a === "-h") {
47
+ console.log("用法: node check-landing.mjs --project <项目根目录> [--json]");
48
+ process.exit(EXIT_OK);
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ // ────────────────────────────── 文件遍历
55
+
56
+ function walk(dir, extSet, acc = []) {
57
+ let entries;
58
+ try {
59
+ entries = fs.readdirSync(dir, { withFileTypes: true });
60
+ } catch {
61
+ return acc;
62
+ }
63
+ for (const e of entries) {
64
+ if (e.isDirectory()) {
65
+ if (IGNORE_DIRS.has(e.name)) continue;
66
+ walk(path.join(dir, e.name), extSet, acc);
67
+ } else if (e.isFile()) {
68
+ if (extSet === null || extSet.has(path.extname(e.name).toLowerCase())) {
69
+ acc.push(path.join(dir, e.name));
70
+ }
71
+ }
72
+ }
73
+ return acc;
74
+ }
75
+
76
+ function readSafe(p) {
77
+ try {
78
+ return fs.readFileSync(p, "utf-8");
79
+ } catch {
80
+ return "";
81
+ }
82
+ }
83
+
84
+ function rel(root, p) {
85
+ return path.relative(root, p).split(path.sep).join("/");
86
+ }
87
+
88
+ function exists(p) {
89
+ try {
90
+ fs.statSync(p);
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ // ────────────────────────────── 契约侧
98
+
99
+ function findContract(projectRoot) {
100
+ const designDir = path.join(projectRoot, ".design");
101
+ if (!exists(designDir)) return { file: null, reason: "项目根下没有 .design/ 目录" };
102
+
103
+ let names;
104
+ try {
105
+ names = fs.readdirSync(designDir);
106
+ } catch {
107
+ return { file: null, reason: ".design/ 目录无法读取" };
108
+ }
109
+ const hit = names.find((n) => /^visual-contract.*\.json$/.test(n));
110
+ if (!hit) {
111
+ return { file: null, reason: ".design/ 下没有 visual-contract-*.json" };
112
+ }
113
+ return { file: path.join(designDir, hit), name: hit, reason: null };
114
+ }
115
+
116
+ function inspectContract(contract) {
117
+ const problems = [];
118
+ const warns = [];
119
+ const info = {};
120
+
121
+ if (!contract || typeof contract !== "object") {
122
+ return { ok: false, problems: ["契约内容不是合法 JSON 对象"], warns, info };
123
+ }
124
+
125
+ info.schema = contract.schema || "(缺失)";
126
+ if (contract.schema !== "ui.visual-contract/1.0") {
127
+ warns.push(`schema 是 "${contract.schema}",期望 "ui.visual-contract/1.0"`);
128
+ }
129
+
130
+ const meta = contract.meta || {};
131
+ info.scene = meta.scene || "(缺失)";
132
+ info.pageTypes = Array.isArray(meta.pageTypes) ? meta.pageTypes.join(",") : "(缺失)";
133
+ info.mockup = (meta.source && meta.source.mockup) || "(缺失)";
134
+ if (!meta.scene) problems.push("meta.scene 缺失 —— 项目侧无法判断用哪套角色与布局预期");
135
+ if (!meta.source || !meta.source.mockup) {
136
+ problems.push("meta.source.mockup 缺失 —— 目视对照没有比对目标");
137
+ }
138
+
139
+ const colorTokens = (contract.tokens && contract.tokens.color) || {};
140
+ info.tokenCount = Object.keys(colorTokens).length;
141
+ if (info.tokenCount === 0) problems.push("tokens.color 为空 —— 令牌层没有值");
142
+
143
+ const roles = contract.roles && typeof contract.roles === "object" ? contract.roles : {};
144
+ const roleNames = Object.keys(roles);
145
+ info.roleCount = roleNames.length;
146
+ const emptyRoles = roleNames.filter((n) => {
147
+ const r = roles[n] || {};
148
+ const hasHost = Array.isArray(r.host) && r.host.length > 0;
149
+ const hasInner = Array.isArray(r.inner) && r.inner.length > 0;
150
+ return !hasHost && !hasInner;
151
+ });
152
+ info.emptyRoles = emptyRoles;
153
+ if (roleNames.length === 0) {
154
+ problems.push("roles 为空 —— 角色层没抽出来,三份 CSS 只能生成空壳");
155
+ } else if (emptyRoles.length > 0) {
156
+ problems.push(`这些角色没有任何声明: ${emptyRoles.join(", ")}`);
157
+ }
158
+ if (roleNames.length > 0 && roleNames.length < 5) {
159
+ warns.push(`契约只有 ${roleNames.length} 个角色,很可能设计侧抽取不完整(一页通常 20+ 个角色)`);
160
+ }
161
+
162
+ const layout = contract.layout || {};
163
+ const areas = Array.isArray(layout.areas) ? layout.areas : [];
164
+ info.areaCount = areas.length;
165
+ info.grid = layout.grid || "(缺失)";
166
+ if (areas.length === 0) {
167
+ problems.push("layout.areas 为空 —— 布局层没定,骨架无法生成");
168
+ }
169
+
170
+ const pages = contract.pages || {};
171
+ info.pageTypesDeclared = Object.keys(pages).join(",");
172
+ if (Object.keys(pages).length === 0) {
173
+ warns.push("pages 为空 —— 没有区域到角色的绑定,区域内部不知道该用什么角色");
174
+ }
175
+
176
+ return { ok: problems.length === 0, problems, warns, info, roleNames };
177
+ }
178
+
179
+ // ────────────────────────────── CSS 侧
180
+
181
+ function findGeneratedCss(projectRoot) {
182
+ const found = { files: [], pathOf: {} };
183
+ const allFiles = walk(projectRoot, null, []);
184
+ for (const name of CSS_FILES) {
185
+ const hits = allFiles.filter((p) => path.basename(p) === name);
186
+ if (hits.length > 0) {
187
+ found.files.push(hits[0]);
188
+ found.pathOf[name] = hits[0];
189
+ }
190
+ }
191
+ return found;
192
+ }
193
+
194
+ function checkImported(projectRoot, cssFileAbs) {
195
+ const base = path.basename(cssFileAbs);
196
+ const sources = walk(projectRoot, SOURCE_EXT, []);
197
+ const refs = [];
198
+ for (const s of sources) {
199
+ const content = readSafe(s);
200
+ if (content.includes(base)) refs.push(rel(projectRoot, s));
201
+ }
202
+ const styleRefs = [];
203
+ for (const s of walk(projectRoot, STYLE_EXT, [])) {
204
+ if (path.basename(s) === base) continue;
205
+ const content = readSafe(s);
206
+ if (content.includes(base)) styleRefs.push(rel(projectRoot, s));
207
+ }
208
+ return { imported: refs.length > 0 || styleRefs.length > 0, refs, styleRefs };
209
+ }
210
+
211
+ // ────────────────────────────── 挂载侧
212
+
213
+ function expectedClassName(roleName) {
214
+ return (
215
+ "role-" +
216
+ roleName
217
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
218
+ .replace(/\./g, "-")
219
+ .replace(/_/g, "-")
220
+ .toLowerCase()
221
+ );
222
+ }
223
+
224
+ function checkMounting(projectRoot, roleNames) {
225
+ const vueFiles = walk(projectRoot, SOURCE_EXT, []);
226
+ const roleClassRe = /\brole-[a-z0-9]+(?:-[a-z0-9]+)*/g;
227
+ const usedClasses = new Map();
228
+ for (const s of vueFiles) {
229
+ const content = readSafe(s);
230
+ const hits = content.match(roleClassRe);
231
+ if (!hits) continue;
232
+ for (const h of hits) {
233
+ if (!usedClasses.has(h)) usedClasses.set(h, new Set());
234
+ usedClasses.get(h).add(rel(projectRoot, s));
235
+ }
236
+ }
237
+
238
+ const expected = roleNames.map(expectedClassName);
239
+ const mounted = [];
240
+ const missing = [];
241
+ for (let i = 0; i < roleNames.length; i++) {
242
+ if (usedClasses.has(expected[i])) mounted.push(expected[i]);
243
+ else missing.push(`${roleNames[i]} → .${expected[i]}`);
244
+ }
245
+
246
+ const layoutClassRe = /\bui-(?:page|region-[a-z0-9]+(?:-[a-z0-9]+)*)\b/g;
247
+ const layoutClasses = new Set();
248
+ for (const s of vueFiles) {
249
+ const content = readSafe(s);
250
+ const hits = content.match(layoutClassRe);
251
+ if (hits) for (const h of hits) layoutClasses.add(h);
252
+ }
253
+
254
+ return {
255
+ scannedFileCount: vueFiles.length,
256
+ mounted,
257
+ missing,
258
+ layoutClasses: [...layoutClasses],
259
+ hasLayout: layoutClasses.has("ui-page"),
260
+ };
261
+ }
262
+
263
+ // ────────────────────────────── 主流程
264
+
265
+ function run(projectRoot) {
266
+ const checks = [];
267
+ const add = (id, title, status, detail, fix) =>
268
+ checks.push({ id, title, status, detail, fix });
269
+
270
+ if (!exists(projectRoot)) {
271
+ console.error(`项目根目录不存在: ${projectRoot}`);
272
+ process.exit(EXIT_FAIL);
273
+ }
274
+
275
+ // 1. 契约存在
276
+ const cf = findContract(projectRoot);
277
+ if (!cf.file) {
278
+ add("contract-found", "契约文件存在", "FAIL", cf.reason,
279
+ "设计侧没有落盘契约。检查是否在项目根目录运行、是否完成了第 6 步「出契约」。");
280
+ return { checks, ok: false, projectRoot };
281
+ }
282
+ add("contract-found", "契约文件存在", "PASS", `.design/${cf.name}`, null);
283
+
284
+ // 2. 契约完整
285
+ let contract = null;
286
+ let parsed = null;
287
+ try {
288
+ contract = JSON.parse(readSafe(cf.file));
289
+ } catch (e) {
290
+ add("contract-valid", "契约内容完整", "FAIL", `JSON 解析失败: ${e.message}`,
291
+ "契约是手工编辑过的吗?重新用设计侧生成一份。");
292
+ return { checks, ok: false, projectRoot };
293
+ }
294
+ parsed = inspectContract(contract);
295
+ add(
296
+ "contract-valid",
297
+ "契约内容完整",
298
+ parsed.ok ? "PASS" : "FAIL",
299
+ `场景=${parsed.info.scene} 页型=${parsed.info.pageTypes} 角色=${parsed.info.roleCount} 区域=${parsed.info.areaCount}`,
300
+ parsed.problems.length ? parsed.problems.join(";") : null
301
+ );
302
+ for (const w of parsed.warns || []) {
303
+ add(`contract-warn-${checks.length}`, "契约告警", "WARN", w, null);
304
+ }
305
+ if (!parsed.ok) {
306
+ return { checks, ok: false, projectRoot, contractInfo: parsed.info };
307
+ }
308
+
309
+ // 3. 三份 CSS 生成
310
+ const css = findGeneratedCss(projectRoot);
311
+ const missingCss = CSS_FILES.filter((n) => !css.pathOf[n]);
312
+ add(
313
+ "css-generated",
314
+ "三份 CSS 已生成",
315
+ missingCss.length === 0 ? "PASS" : "FAIL",
316
+ missingCss.length === 0
317
+ ? CSS_FILES.map((n) => rel(projectRoot, css.pathOf[n])).join(" ")
318
+ : `缺: ${missingCss.join(", ")}`,
319
+ missingCss.length
320
+ ? "项目侧未完成第 3/4/5 步。按 design-apply.md 的翻译规则生成这三份文件。"
321
+ : null
322
+ );
323
+ if (missingCss.length === 0) {
324
+ // 4. CSS 被引入
325
+ const unimported = [];
326
+ const importDetail = [];
327
+ for (const n of CSS_FILES) {
328
+ const r = checkImported(projectRoot, css.pathOf[n]);
329
+ if (!r.imported) unimported.push(n);
330
+ importDetail.push(`${n}:${r.imported ? "已引入 " + (r.refs[0] || r.styleRefs[0]) : "未引入"}`);
331
+ }
332
+ add(
333
+ "css-imported",
334
+ "三份 CSS 被入口引入",
335
+ unimported.length === 0 ? "PASS" : "FAIL",
336
+ importDetail.join(" | "),
337
+ unimported.length
338
+ ? `${unimported.join(", ")} 生成了但没被任何文件 import —— 浏览器不会加载它们。在样式入口 import 一次。`
339
+ : null
340
+ );
341
+ }
342
+
343
+ // 5. 角色类挂载
344
+ const mount = checkMounting(projectRoot, parsed.roleNames);
345
+ const mountRatio = parsed.roleNames.length
346
+ ? mount.mounted.length / parsed.roleNames.length
347
+ : 0;
348
+ add(
349
+ "roles-mounted",
350
+ "角色类挂到模板上",
351
+ mount.mounted.length === parsed.roleNames.length ? "PASS"
352
+ : mount.mounted.length === 0 ? "FAIL" : "WARN",
353
+ `契约 ${parsed.roleNames.length} 个角色,模板挂载 ${mount.mounted.length} 个(覆盖率 ${(mountRatio * 100).toFixed(0)}%);扫描 ${mount.scannedFileCount} 个源文件`,
354
+ mount.mounted.length === 0
355
+ ? "没有任何 .vue 文件出现 role-* 类名 —— 三份 CSS 全是死样式(写了规则但没元素承载)。按 adapter 的挂载映射给组件加 class。"
356
+ : mount.missing.length
357
+ ? `未挂载 ${mount.missing.length} 个角色(CSS 已生成但页面上没有对应 class,视觉不会变): ${mount.missing.slice(0, 6).join(" | ")}${mount.missing.length > 6 ? " …" : ""}`
358
+ : null
359
+ );
360
+
361
+ // 6. 布局类挂载
362
+ add(
363
+ "layout-mounted",
364
+ "布局类挂到容器上",
365
+ mount.hasLayout ? "PASS" : "FAIL",
366
+ mount.hasLayout
367
+ ? `布局类: ${mount.layoutClasses.join(", ")}`
368
+ : "没有任何 .vue 文件出现 .ui-page —— 布局层未接入,骨架仍是项目原有结构",
369
+ mount.hasLayout ? null : "布局组件需加 .ui-page 与 .ui-region-* 类,见 adapter 的「布局占位容器」。"
370
+ );
371
+
372
+ const ok = checks.every((c) => c.status === "PASS");
373
+ return { checks, ok, projectRoot, contractInfo: parsed.info, mount };
374
+ }
375
+
376
+ // ────────────────────────────── 输出
377
+
378
+ function printHuman(result) {
379
+ const L = [];
380
+ L.push("");
381
+ L.push("契约落地自检");
382
+ L.push("=".repeat(64));
383
+ L.push(`项目根: ${result.projectRoot}`);
384
+ L.push("");
385
+ const mark = { PASS: "PASS", WARN: "WARN", FAIL: "FAIL" };
386
+ for (const c of result.checks) {
387
+ L.push(`[${mark[c.status]}] ${c.id} · ${c.title}`);
388
+ if (c.detail) L.push(` ${c.detail}`);
389
+ if (c.fix) L.push(` 修复 → ${c.fix}`);
390
+ }
391
+ L.push("");
392
+ L.push("-".repeat(64));
393
+ const firstFail = result.checks.find((c) => c.status === "FAIL");
394
+ if (result.ok) {
395
+ L.push("结论: 六项全过 —— 契约、样式、挂载三个闭环均已接通。");
396
+ } else if (firstFail) {
397
+ L.push(`结论: 断在「${firstFail.title}」。先修这一项,重跑本脚本看下一项。`);
398
+ L.push("");
399
+ L.push("提醒: 项目侧的修正动作是「改契约或改 adapter 后重新生成」,");
400
+ L.push(" 不要直接改已生成的 CSS —— 下次重新生成会被覆盖。");
401
+ } else {
402
+ L.push("结论: 有 WARN 项,落地链路未全覆盖。");
403
+ }
404
+ L.push("");
405
+ process.stdout.write(L.join("\n") + "\n");
406
+ }
407
+
408
+ function main() {
409
+ const args = parseArgs(process.argv.slice(2));
410
+ const result = run(args.project);
411
+ if (args.json) {
412
+ console.log(JSON.stringify(result, null, 2));
413
+ } else {
414
+ printHuman(result);
415
+ }
416
+ process.exit(result.ok ? EXIT_OK : EXIT_FAIL);
417
+ }
418
+
419
+ main();