@agile-team/wl-skills-kit 2.11.0 → 2.11.1
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/CHANGELOG.md +34 -9
- package/README.md +41 -23
- package/bin/wl-skills.js +108 -38
- package/docs/agent-pipeline-runbook.md +3 -3
- package/docs//345/205/250/347/233/230/345/210/206/346/236/220/344/270/216/346/231/272/350/203/275/344/275/223/346/220/255/345/273/272/346/214/207/345/215/227.md +4 -4
- package/files/.wl-skills/copilot-instructions-full.md +233 -233
- package/files/.wl-skills/docs/page-spec-schema.md +109 -0
- package/files/.wl-skills/guides/architecture.md +1 -1
- package/files/.wl-skills/skills/core/page-codegen/SKILL.md +10 -4
- package/files/.wl-skills/standards/14-layout-containers.md +6 -6
- package/lib/page-spec.js +588 -0
- package/lib/safe-fix.js +115 -0
- package/mcp/config.js +3 -3
- package/mcp/tools/projectTools.js +10 -0
- package/package.json +16 -11
- package/files/.wl-skills/src/components/global/C_Splitter/index.scss +0 -61
- package/files/.wl-skills/src/components/global/C_Splitter/index.vue +0 -149
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 14 — 布局容器规范(C_Splitter
|
|
1
|
+
# 14 — 布局容器规范(C_Splitter 已删除 + jh-drag-col/row 唯一推荐)
|
|
2
2
|
|
|
3
3
|
> **强制度**:🔴 必遵 + 阻断式(lint 命中即报错)。
|
|
4
4
|
> **背景**:2024 年 12 月一次真实事故,左树右表页面右侧面板永不刷新,最终定位为 `C_Splitter` 在 `onMounted` 中调用 `slots.default()` 冻结 vnode 快照,导致子树所有响应式绑定与父组件 ref 脱钩。
|
|
@@ -94,13 +94,13 @@ onMounted(() => {
|
|
|
94
94
|
|
|
95
95
|
---
|
|
96
96
|
|
|
97
|
-
## 4.
|
|
97
|
+
## 4. 存量改造
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
`C_Splitter` 组件已从 kit 中**彻底删除**(不再随包分发)。若存量项目仍引用:
|
|
100
100
|
|
|
101
|
-
1.
|
|
102
|
-
2.
|
|
103
|
-
3.
|
|
101
|
+
1. **必须替换**:所有 `import C_Splitter` / `<C_Splitter>` 全量替换为 `jh-drag-col` / `jh-drag-row`
|
|
102
|
+
2. **lint 规则**:`wl-skills validate` / `wl-skills doctor-ui` / `lint-skills` 命中 `import C_Splitter` 或 `<C_Splitter` 一律报 ERROR,无任何豁免
|
|
103
|
+
3. **删除残留组件目录**:若项目 `src/components/global/C_Splitter/` 仍存在,可直接删除
|
|
104
104
|
|
|
105
105
|
---
|
|
106
106
|
|
package/lib/page-spec.js
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/page-spec.js — page-spec 落盘 + spec-align 确定性比对引擎
|
|
5
|
+
*
|
|
6
|
+
* 解决的核心问题:
|
|
7
|
+
* page-codegen / spec-doc-parse / prototype-scan 的"精准实现"约定
|
|
8
|
+
* (查询字段顺序、表格列顺序、按钮顺序与颜色、操作列严格对应、按钮文字保真)
|
|
9
|
+
* 过去只活在对话上下文里,没有机器可比对的真值,validate 无法验证"是否按约定实现"。
|
|
10
|
+
*
|
|
11
|
+
* 本模块把 page-spec 固化为页面目录下的 `page-spec.json`(单一真值),
|
|
12
|
+
* 再用 AST/括号匹配解析 data.ts 的 queryDef/columnsDef/toolbarDef,
|
|
13
|
+
* 与 page-spec 做确定性比对,输出 S1~S5 偏差:
|
|
14
|
+
* S1: 查询字段顺序不一致(query) → warn
|
|
15
|
+
* S2: 表格列顺序/集合不一致(columns) → error
|
|
16
|
+
* S3: 工具栏按钮顺序/集合/颜色不一致(toolbar)→ error
|
|
17
|
+
* S4: 操作列按钮集合不一致(operations) → error
|
|
18
|
+
* S5: 按钮/列 label 文字与原型不保真 → warn
|
|
19
|
+
*
|
|
20
|
+
* 设计原则:
|
|
21
|
+
* - 找不到 page-spec.json 时静默跳过(不是所有页面都有 spec)
|
|
22
|
+
* - 解析失败降级为 info 提示,不误报阻断
|
|
23
|
+
* - 仅做"约定 vs 代码"的确定性核对,不做语义推断
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require("fs");
|
|
27
|
+
const path = require("path");
|
|
28
|
+
|
|
29
|
+
// ─── page-spec JSON Schema(文档 + 运行时校验依据)────────────────────────
|
|
30
|
+
//
|
|
31
|
+
// {
|
|
32
|
+
// "page": "客户档案", // 页面中文名
|
|
33
|
+
// "dir": "src/views/mdata/customer", // 页面目录(相对项目根)
|
|
34
|
+
// "mode": "LIST", // 交互模式
|
|
35
|
+
// "query": [{ "name": "code", "label": "客户编码" }, ...], // 查询字段(左→右、上→下)
|
|
36
|
+
// "columns": [{ "name": "code", "label": "客户编码" }, ...], // 表格列(左→右,selection/index 在前可省略)
|
|
37
|
+
// "toolbar": [{ "label": "新增", "color": "primary", "plain": false }, ...], // 工具栏按钮(左→右)
|
|
38
|
+
// "operations": [{ "label": "编辑" }, { "label": "删除" }] // 操作列按钮
|
|
39
|
+
// }
|
|
40
|
+
//
|
|
41
|
+
// color 取值:primary / danger / warning / success / default
|
|
42
|
+
|
|
43
|
+
const SPEC_FILENAME = "page-spec.json";
|
|
44
|
+
|
|
45
|
+
const VALID_COLORS = new Set([
|
|
46
|
+
"primary",
|
|
47
|
+
"danger",
|
|
48
|
+
"warning",
|
|
49
|
+
"success",
|
|
50
|
+
"default",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 在页面目录中查找 page-spec.json
|
|
55
|
+
* @returns {string|null} 绝对路径
|
|
56
|
+
*/
|
|
57
|
+
function findPageSpecPath(absDir) {
|
|
58
|
+
const p = path.join(absDir, SPEC_FILENAME);
|
|
59
|
+
return fs.existsSync(p) ? p : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 读取并解析 page-spec.json
|
|
64
|
+
* @returns {{ spec: object|null, error: string|null }}
|
|
65
|
+
*/
|
|
66
|
+
function readPageSpec(absDir) {
|
|
67
|
+
const p = findPageSpecPath(absDir);
|
|
68
|
+
if (!p) return { spec: null, error: null };
|
|
69
|
+
try {
|
|
70
|
+
const spec = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
71
|
+
return { spec, error: null };
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return { spec: null, error: "page-spec.json 解析失败:" + e.message };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 校验 page-spec 结构合法性(写入前/读取后均可调用)
|
|
79
|
+
* @returns {string[]} 错误列表(空数组 = 合法)
|
|
80
|
+
*/
|
|
81
|
+
function validateSpecShape(spec) {
|
|
82
|
+
const errs = [];
|
|
83
|
+
if (!spec || typeof spec !== "object") {
|
|
84
|
+
return ["page-spec 不是合法对象"];
|
|
85
|
+
}
|
|
86
|
+
if (!spec.page || typeof spec.page !== "string") {
|
|
87
|
+
errs.push("缺少 page(页面中文名)");
|
|
88
|
+
}
|
|
89
|
+
for (const key of ["query", "columns", "toolbar", "operations"]) {
|
|
90
|
+
if (spec[key] !== undefined && !Array.isArray(spec[key])) {
|
|
91
|
+
errs.push(key + " 必须是数组");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const btn of spec.toolbar || []) {
|
|
95
|
+
if (btn && btn.color && !VALID_COLORS.has(btn.color)) {
|
|
96
|
+
errs.push(
|
|
97
|
+
'工具栏按钮 "' +
|
|
98
|
+
(btn.label || "?") +
|
|
99
|
+
'" 的 color 非法:' +
|
|
100
|
+
btn.color +
|
|
101
|
+
"(合法值:primary/danger/warning/success/default)",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return errs;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── data.ts 解析:括号匹配提取方法体 ────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 提取形如 `methodName() { ... }` 或 `methodName(): Type { ... }` 的方法体内容
|
|
112
|
+
* 用括号配平精确截取,避免正则误吞。
|
|
113
|
+
* @returns {string|null}
|
|
114
|
+
*/
|
|
115
|
+
function extractMethodBody(source, methodName) {
|
|
116
|
+
if (!source) return null;
|
|
117
|
+
// 匹配方法签名起点:methodName ( ... ) ... {
|
|
118
|
+
const sigRe = new RegExp(methodName + "\\s*\\([^)]*\\)[^{]*\\{");
|
|
119
|
+
const m = sigRe.exec(source);
|
|
120
|
+
if (!m) return null;
|
|
121
|
+
const start = m.index + m[0].length; // { 之后
|
|
122
|
+
let depth = 1;
|
|
123
|
+
let i = start;
|
|
124
|
+
while (i < source.length && depth > 0) {
|
|
125
|
+
const ch = source[i];
|
|
126
|
+
if (ch === "{") depth++;
|
|
127
|
+
else if (ch === "}") depth--;
|
|
128
|
+
i++;
|
|
129
|
+
}
|
|
130
|
+
if (depth !== 0) return null;
|
|
131
|
+
return source.slice(start, i - 1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 去注释/字符串(保留引号结构),用于结构匹配前清洗
|
|
136
|
+
*/
|
|
137
|
+
function stripNoise(code) {
|
|
138
|
+
if (!code) return "";
|
|
139
|
+
let r = code.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
140
|
+
r = r.replace(/\/\/[^\n]*/g, "");
|
|
141
|
+
return r;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 从方法体中按出现顺序提取顶层对象的 name 与 label。
|
|
146
|
+
* 仅提取数组元素级别的 name/label(不深入嵌套对象),用顺序保真比对。
|
|
147
|
+
*
|
|
148
|
+
* 返回 [{ name, label }],顺序即代码顺序。
|
|
149
|
+
*/
|
|
150
|
+
function extractFieldSequence(methodBody) {
|
|
151
|
+
if (!methodBody) return [];
|
|
152
|
+
const result = [];
|
|
153
|
+
// 以对象起始 `{` 为切分点,逐个对象提取首个 name/label
|
|
154
|
+
// 通过括号配平拆分数组中的顶层对象
|
|
155
|
+
const items = splitTopLevelObjects(methodBody);
|
|
156
|
+
for (const item of items) {
|
|
157
|
+
const nameM = item.match(/(?:^|[\s,{])name\s*:\s*["'`]([^"'`]+)["'`]/);
|
|
158
|
+
const labelM = item.match(/(?:^|[\s,{])label\s*:\s*["'`]([^"'`]+)["'`]/);
|
|
159
|
+
if (nameM || labelM) {
|
|
160
|
+
result.push({
|
|
161
|
+
name: nameM ? nameM[1] : null,
|
|
162
|
+
label: labelM ? labelM[1] : null,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 把方法体内最外层数组中的顶层对象切分出来。
|
|
171
|
+
* 找到第一个 `[`,在其内做括号配平,按逗号在 depth=1 处分割对象。
|
|
172
|
+
*/
|
|
173
|
+
function splitTopLevelObjects(body) {
|
|
174
|
+
const clean = stripNoise(body);
|
|
175
|
+
const lb = clean.indexOf("[");
|
|
176
|
+
if (lb < 0) return [];
|
|
177
|
+
let depth = 0;
|
|
178
|
+
let i = lb;
|
|
179
|
+
let arrEnd = -1;
|
|
180
|
+
for (; i < clean.length; i++) {
|
|
181
|
+
const ch = clean[i];
|
|
182
|
+
if (ch === "[") depth++;
|
|
183
|
+
else if (ch === "]") {
|
|
184
|
+
depth--;
|
|
185
|
+
if (depth === 0) {
|
|
186
|
+
arrEnd = i;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (arrEnd < 0) return [];
|
|
192
|
+
const arrBody = clean.slice(lb + 1, arrEnd);
|
|
193
|
+
// 在 arrBody 中按顶层 `{...}` 提取对象
|
|
194
|
+
const objects = [];
|
|
195
|
+
let objDepth = 0;
|
|
196
|
+
let objStart = -1;
|
|
197
|
+
for (let j = 0; j < arrBody.length; j++) {
|
|
198
|
+
const ch = arrBody[j];
|
|
199
|
+
if (ch === "{") {
|
|
200
|
+
if (objDepth === 0) objStart = j;
|
|
201
|
+
objDepth++;
|
|
202
|
+
} else if (ch === "}") {
|
|
203
|
+
objDepth--;
|
|
204
|
+
if (objDepth === 0 && objStart >= 0) {
|
|
205
|
+
objects.push(arrBody.slice(objStart, j + 1));
|
|
206
|
+
objStart = -1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return objects;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 提取工具栏按钮序列(含颜色推断)。
|
|
215
|
+
* 工具栏对象常见结构:{ name: "primary", label: "新增", plain: true, type: "danger" }
|
|
216
|
+
* 颜色来源优先级:type > name(name 既是语义也是颜色)
|
|
217
|
+
*/
|
|
218
|
+
function extractToolbarSequence(methodBody) {
|
|
219
|
+
if (!methodBody) return [];
|
|
220
|
+
const items = splitTopLevelObjects(methodBody);
|
|
221
|
+
const result = [];
|
|
222
|
+
for (const item of items) {
|
|
223
|
+
const labelM = item.match(/(?:^|[\s,{])label\s*:\s*["'`]([^"'`]+)["'`]/);
|
|
224
|
+
if (!labelM) continue;
|
|
225
|
+
const nameM = item.match(/(?:^|[\s,{])name\s*:\s*["'`]([^"'`]+)["'`]/);
|
|
226
|
+
const typeM = item.match(/(?:^|[\s,{])type\s*:\s*["'`]([^"'`]+)["'`]/);
|
|
227
|
+
const plainM = /(?:^|[\s,{])plain\s*:\s*true/.test(item);
|
|
228
|
+
let color = typeM ? typeM[1] : nameM ? nameM[1] : "default";
|
|
229
|
+
if (!VALID_COLORS.has(color)) color = "default";
|
|
230
|
+
result.push({ label: labelM[1], color, plain: plainM });
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 提取操作列按钮序列。
|
|
237
|
+
* 操作列由 renderOps([{ type, label, onClick }]) 渲染,或 operations: [...]
|
|
238
|
+
* label 缺省时按 type 推断中文(edit→编辑 / del|danger→删除 / view→查看)
|
|
239
|
+
*/
|
|
240
|
+
function extractOperationSequence(dataContent) {
|
|
241
|
+
if (!dataContent) return [];
|
|
242
|
+
const clean = stripNoise(dataContent);
|
|
243
|
+
// 优先匹配 renderOps([...])
|
|
244
|
+
const renderM = /renderOps\s*\(\s*\[/.exec(clean);
|
|
245
|
+
let body = null;
|
|
246
|
+
if (renderM) {
|
|
247
|
+
const start = renderM.index + renderM[0].length - 1; // 指向 [
|
|
248
|
+
body = extractBracketBody(clean, start);
|
|
249
|
+
}
|
|
250
|
+
// 兼容旧写法 operations: [...],validate 其他规则仍会提示改用 renderOps()
|
|
251
|
+
if (!body) {
|
|
252
|
+
const operationsM = /\boperations\s*:\s*\[/.exec(clean);
|
|
253
|
+
if (operationsM) {
|
|
254
|
+
const start = operationsM.index + operationsM[0].length - 1; // 指向 [
|
|
255
|
+
body = extractBracketBody(clean, start);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (!body) return [];
|
|
259
|
+
const items = splitTopLevelObjects("[" + body + "]");
|
|
260
|
+
const TYPE_LABEL = { edit: "编辑", del: "删除", danger: "删除", view: "查看" };
|
|
261
|
+
const result = [];
|
|
262
|
+
for (const item of items) {
|
|
263
|
+
const labelM = item.match(/(?:^|[\s,{])label\s*:\s*["'`]([^"'`]+)["'`]/);
|
|
264
|
+
const typeM = item.match(/(?:^|[\s,{])type\s*:\s*["'`]([^"'`]+)["'`]/);
|
|
265
|
+
let label = labelM ? labelM[1] : typeM ? TYPE_LABEL[typeM[1]] : null;
|
|
266
|
+
if (label) result.push({ label });
|
|
267
|
+
}
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 从 `[` 位置做括号配平,返回内部内容(不含外层括号) */
|
|
272
|
+
function extractBracketBody(source, openIdx) {
|
|
273
|
+
if (source[openIdx] !== "[") return null;
|
|
274
|
+
let depth = 0;
|
|
275
|
+
for (let i = openIdx; i < source.length; i++) {
|
|
276
|
+
const ch = source[i];
|
|
277
|
+
if (ch === "[") depth++;
|
|
278
|
+
else if (ch === "]") {
|
|
279
|
+
depth--;
|
|
280
|
+
if (depth === 0) return source.slice(openIdx + 1, i);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── 比对 ────────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
function seqNames(seq) {
|
|
289
|
+
return seq.map((x) => x.name).filter(Boolean);
|
|
290
|
+
}
|
|
291
|
+
function seqLabels(seq) {
|
|
292
|
+
return seq.map((x) => x.label).filter(Boolean);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 数组顺序是否严格相等 */
|
|
296
|
+
function arrayEq(a, b) {
|
|
297
|
+
if (a.length !== b.length) return false;
|
|
298
|
+
return a.every((x, i) => x === b[i]);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 集合是否相等(忽略顺序) */
|
|
302
|
+
function setEq(a, b) {
|
|
303
|
+
if (a.length !== b.length) return false;
|
|
304
|
+
const sa = new Set(a);
|
|
305
|
+
return b.every((x) => sa.has(x)) && a.every((x) => new Set(b).has(x));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function pushMissingImplementationIssue(issues, level, dir, rule, target) {
|
|
309
|
+
issues.push({
|
|
310
|
+
level,
|
|
311
|
+
dir,
|
|
312
|
+
rule,
|
|
313
|
+
text: "page-spec 声明了 " + target + ",但 data.ts 中未解析到对应实现",
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 比对 page-spec 与 data.ts 实际实现
|
|
319
|
+
* @param {object} spec page-spec.json 对象
|
|
320
|
+
* @param {string} dataContent data.ts 源码
|
|
321
|
+
* @param {string} dir 页面相对目录(用于 issue.dir)
|
|
322
|
+
* @returns {Array<{level,dir,text,rule}>}
|
|
323
|
+
*/
|
|
324
|
+
function compareSpecToCode(spec, dataContent, dir) {
|
|
325
|
+
const issues = [];
|
|
326
|
+
if (!spec) return issues;
|
|
327
|
+
|
|
328
|
+
// S1: 查询字段顺序(query)
|
|
329
|
+
if (Array.isArray(spec.query) && spec.query.length > 0) {
|
|
330
|
+
const body = extractMethodBody(dataContent, "queryDef");
|
|
331
|
+
const specNames = spec.query.map((q) => q.name).filter(Boolean);
|
|
332
|
+
if (!body) {
|
|
333
|
+
pushMissingImplementationIssue(issues, "warn", dir, "S1", "queryDef()");
|
|
334
|
+
} else {
|
|
335
|
+
const actual = extractFieldSequence(body);
|
|
336
|
+
const actualNames = seqNames(actual);
|
|
337
|
+
if (specNames.length && actualNames.length === 0) {
|
|
338
|
+
pushMissingImplementationIssue(issues, "warn", dir, "S1", "queryDef() 查询字段");
|
|
339
|
+
} else if (specNames.length && !setEq(specNames, actualNames)) {
|
|
340
|
+
const missing = specNames.filter((n) => !actualNames.includes(n));
|
|
341
|
+
const extra = actualNames.filter((n) => !specNames.includes(n));
|
|
342
|
+
issues.push({
|
|
343
|
+
level: "warn",
|
|
344
|
+
dir,
|
|
345
|
+
rule: "S1",
|
|
346
|
+
text:
|
|
347
|
+
"查询字段与 page-spec 不一致" +
|
|
348
|
+
(missing.length ? "(缺:" + missing.join(",") + ")" : "") +
|
|
349
|
+
(extra.length ? "(多:" + extra.join(",") + ")" : ""),
|
|
350
|
+
});
|
|
351
|
+
} else if (specNames.length && !arrayEq(specNames, actualNames)) {
|
|
352
|
+
issues.push({
|
|
353
|
+
level: "warn",
|
|
354
|
+
dir,
|
|
355
|
+
rule: "S1",
|
|
356
|
+
text:
|
|
357
|
+
"查询字段顺序与原型不一致:spec[" +
|
|
358
|
+
specNames.join(",") +
|
|
359
|
+
"] vs code[" +
|
|
360
|
+
actualNames.join(",") +
|
|
361
|
+
"]",
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// S2: 表格列顺序/集合(columns)
|
|
368
|
+
if (Array.isArray(spec.columns) && spec.columns.length > 0) {
|
|
369
|
+
const body = extractMethodBody(dataContent, "columnsDef");
|
|
370
|
+
const specNames = spec.columns.map((c) => c.name).filter(Boolean);
|
|
371
|
+
if (!body) {
|
|
372
|
+
pushMissingImplementationIssue(issues, "error", dir, "S2", "columnsDef()");
|
|
373
|
+
} else {
|
|
374
|
+
const actual = extractFieldSequence(body);
|
|
375
|
+
// 过滤掉框架内置列(selection/index/_action)
|
|
376
|
+
const actualNames = seqNames(actual).filter(
|
|
377
|
+
(n) => !["selection", "index", "_action"].includes(n),
|
|
378
|
+
);
|
|
379
|
+
if (specNames.length && actualNames.length === 0) {
|
|
380
|
+
pushMissingImplementationIssue(issues, "error", dir, "S2", "columnsDef() 表格列");
|
|
381
|
+
} else if (specNames.length && !setEq(specNames, actualNames)) {
|
|
382
|
+
const missing = specNames.filter((n) => !actualNames.includes(n));
|
|
383
|
+
const extra = actualNames.filter((n) => !specNames.includes(n));
|
|
384
|
+
issues.push({
|
|
385
|
+
level: "error",
|
|
386
|
+
dir,
|
|
387
|
+
rule: "S2",
|
|
388
|
+
text:
|
|
389
|
+
"表格列与 page-spec 不一致" +
|
|
390
|
+
(missing.length ? "(缺:" + missing.join(",") + ")" : "") +
|
|
391
|
+
(extra.length ? "(多:" + extra.join(",") + ")" : ""),
|
|
392
|
+
});
|
|
393
|
+
} else if (specNames.length && !arrayEq(specNames, actualNames)) {
|
|
394
|
+
issues.push({
|
|
395
|
+
level: "error",
|
|
396
|
+
dir,
|
|
397
|
+
rule: "S2",
|
|
398
|
+
text:
|
|
399
|
+
"表格列顺序与原型不一致:spec[" +
|
|
400
|
+
specNames.join(",") +
|
|
401
|
+
"] vs code[" +
|
|
402
|
+
actualNames.join(",") +
|
|
403
|
+
"]",
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// S3: 工具栏按钮顺序/集合/颜色(toolbar)
|
|
410
|
+
if (Array.isArray(spec.toolbar) && spec.toolbar.length > 0) {
|
|
411
|
+
const body = extractMethodBody(dataContent, "toolbarDef");
|
|
412
|
+
const specLabels = spec.toolbar.map((b) => b.label).filter(Boolean);
|
|
413
|
+
if (!body) {
|
|
414
|
+
pushMissingImplementationIssue(issues, "error", dir, "S3", "toolbarDef()");
|
|
415
|
+
} else {
|
|
416
|
+
const actual = extractToolbarSequence(body);
|
|
417
|
+
const actualLabels = actual.map((b) => b.label);
|
|
418
|
+
if (specLabels.length && actualLabels.length === 0) {
|
|
419
|
+
pushMissingImplementationIssue(issues, "error", dir, "S3", "toolbarDef() 工具栏按钮");
|
|
420
|
+
} else if (specLabels.length && !setEq(specLabels, actualLabels)) {
|
|
421
|
+
const missing = specLabels.filter((l) => !actualLabels.includes(l));
|
|
422
|
+
const extra = actualLabels.filter((l) => !specLabels.includes(l));
|
|
423
|
+
issues.push({
|
|
424
|
+
level: "error",
|
|
425
|
+
dir,
|
|
426
|
+
rule: "S3",
|
|
427
|
+
text:
|
|
428
|
+
"工具栏按钮与 page-spec 不一致" +
|
|
429
|
+
(missing.length ? "(缺:" + missing.join(",") + ")" : "") +
|
|
430
|
+
(extra.length ? "(多:" + extra.join(",") + ")" : ""),
|
|
431
|
+
});
|
|
432
|
+
} else if (specLabels.length && !arrayEq(specLabels, actualLabels)) {
|
|
433
|
+
issues.push({
|
|
434
|
+
level: "error",
|
|
435
|
+
dir,
|
|
436
|
+
rule: "S3",
|
|
437
|
+
text:
|
|
438
|
+
"工具栏按钮顺序与原型不一致:spec[" +
|
|
439
|
+
specLabels.join(",") +
|
|
440
|
+
"] vs code[" +
|
|
441
|
+
actualLabels.join(",") +
|
|
442
|
+
"]",
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
// 颜色比对(仅在集合一致时逐个核对)
|
|
446
|
+
if (specLabels.length && actualLabels.length && setEq(specLabels, actualLabels)) {
|
|
447
|
+
const actualByLabel = new Map(actual.map((b) => [b.label, b]));
|
|
448
|
+
for (const sb of spec.toolbar) {
|
|
449
|
+
if (!sb.label || !sb.color) continue;
|
|
450
|
+
const ab = actualByLabel.get(sb.label);
|
|
451
|
+
if (ab && ab.color !== sb.color) {
|
|
452
|
+
issues.push({
|
|
453
|
+
level: "warn",
|
|
454
|
+
dir,
|
|
455
|
+
rule: "S3",
|
|
456
|
+
text:
|
|
457
|
+
'按钮"' +
|
|
458
|
+
sb.label +
|
|
459
|
+
'"颜色与原型不一致:spec=' +
|
|
460
|
+
sb.color +
|
|
461
|
+
" vs code=" +
|
|
462
|
+
ab.color,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// S4: 操作列按钮集合(operations)
|
|
471
|
+
if (Array.isArray(spec.operations) && spec.operations.length > 0) {
|
|
472
|
+
const actual = extractOperationSequence(dataContent);
|
|
473
|
+
const specLabels = spec.operations.map((o) => o.label).filter(Boolean);
|
|
474
|
+
const actualLabels = actual.map((o) => o.label);
|
|
475
|
+
if (specLabels.length && actualLabels.length === 0) {
|
|
476
|
+
pushMissingImplementationIssue(issues, "error", dir, "S4", "renderOps()/operations 操作列按钮");
|
|
477
|
+
} else if (specLabels.length && !setEq(specLabels, actualLabels)) {
|
|
478
|
+
const missing = specLabels.filter((l) => !actualLabels.includes(l));
|
|
479
|
+
const extra = actualLabels.filter((l) => !specLabels.includes(l));
|
|
480
|
+
issues.push({
|
|
481
|
+
level: "error",
|
|
482
|
+
dir,
|
|
483
|
+
rule: "S4",
|
|
484
|
+
text:
|
|
485
|
+
"操作列按钮与 page-spec 不一致" +
|
|
486
|
+
(missing.length ? "(缺:" + missing.join(",") + ")" : "") +
|
|
487
|
+
(extra.length ? "(多:" + extra.join(",") + ",禁止自行添加原型外按钮)" : ""),
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// S5: label 文字保真(query + columns 中 name 相同但 label 被简化/篡改)
|
|
493
|
+
// 例:原型"新增申请"被简化为"新增"、"客户编码"被改为"编码"
|
|
494
|
+
{
|
|
495
|
+
const checkLabelFidelity = (specArr, methodName) => {
|
|
496
|
+
if (!Array.isArray(specArr) || specArr.length === 0) return;
|
|
497
|
+
const body = extractMethodBody(dataContent, methodName);
|
|
498
|
+
if (!body) return;
|
|
499
|
+
const actual = extractFieldSequence(body);
|
|
500
|
+
const actualByName = new Map(
|
|
501
|
+
actual.filter((a) => a.name).map((a) => [a.name, a.label]),
|
|
502
|
+
);
|
|
503
|
+
for (const sf of specArr) {
|
|
504
|
+
if (!sf.name || !sf.label) continue;
|
|
505
|
+
if (!actualByName.has(sf.name)) continue;
|
|
506
|
+
const codeLabel = actualByName.get(sf.name);
|
|
507
|
+
if (codeLabel && codeLabel !== sf.label) {
|
|
508
|
+
issues.push({
|
|
509
|
+
level: "warn",
|
|
510
|
+
dir,
|
|
511
|
+
rule: "S5",
|
|
512
|
+
text:
|
|
513
|
+
'字段"' +
|
|
514
|
+
sf.name +
|
|
515
|
+
'"label 与原型不保真:spec="' +
|
|
516
|
+
sf.label +
|
|
517
|
+
'" vs code="' +
|
|
518
|
+
codeLabel +
|
|
519
|
+
'"',
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
checkLabelFidelity(spec.query, "queryDef");
|
|
525
|
+
checkLabelFidelity(spec.columns, "columnsDef");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return issues;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* 对单个页面目录执行 spec-align 比对
|
|
533
|
+
* @returns {{ issues: Array, hasSpec: boolean }}
|
|
534
|
+
*/
|
|
535
|
+
function alignPage(absDir, relDir) {
|
|
536
|
+
const { spec, error } = readPageSpec(absDir);
|
|
537
|
+
if (error) {
|
|
538
|
+
return { issues: [{ level: "info", dir: relDir, text: error, rule: "S0" }], hasSpec: false };
|
|
539
|
+
}
|
|
540
|
+
if (!spec) return { issues: [], hasSpec: false };
|
|
541
|
+
|
|
542
|
+
const shapeErrs = validateSpecShape(spec);
|
|
543
|
+
if (shapeErrs.length) {
|
|
544
|
+
return {
|
|
545
|
+
issues: shapeErrs.map((e) => ({
|
|
546
|
+
level: "warn",
|
|
547
|
+
dir: relDir,
|
|
548
|
+
rule: "S0",
|
|
549
|
+
text: "page-spec.json 结构问题:" + e,
|
|
550
|
+
})),
|
|
551
|
+
hasSpec: true,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const dataPath = path.join(absDir, "data.ts");
|
|
556
|
+
if (!fs.existsSync(dataPath)) {
|
|
557
|
+
return {
|
|
558
|
+
issues: [
|
|
559
|
+
{
|
|
560
|
+
level: "warn",
|
|
561
|
+
dir: relDir,
|
|
562
|
+
rule: "S0",
|
|
563
|
+
text: "存在 page-spec.json 但缺 data.ts,无法做 spec-align 比对",
|
|
564
|
+
},
|
|
565
|
+
],
|
|
566
|
+
hasSpec: true,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
const dataContent = fs.readFileSync(dataPath, "utf8");
|
|
570
|
+
return { issues: compareSpecToCode(spec, dataContent, relDir), hasSpec: true };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
module.exports = {
|
|
574
|
+
SPEC_FILENAME,
|
|
575
|
+
VALID_COLORS,
|
|
576
|
+
findPageSpecPath,
|
|
577
|
+
readPageSpec,
|
|
578
|
+
validateSpecShape,
|
|
579
|
+
extractMethodBody,
|
|
580
|
+
extractFieldSequence,
|
|
581
|
+
extractToolbarSequence,
|
|
582
|
+
extractOperationSequence,
|
|
583
|
+
splitTopLevelObjects,
|
|
584
|
+
compareSpecToCode,
|
|
585
|
+
alignPage,
|
|
586
|
+
arrayEq,
|
|
587
|
+
setEq,
|
|
588
|
+
};
|