@qilitt-mickey/vue3-temp-skill 1.2.6 → 1.2.8

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.
@@ -1,734 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * generate-page.mjs —— 契约 structure → 页面脚手架 SFC 的确定性生成器
5
- *
6
- * 用法:
7
- * node generate-page.mjs --project <项目根> [--out src/views/design-restore] [--page list]
8
- *
9
- * 职责(design-apply 第 6 步.4 的机器执行版,消灭「凭印象重建页面」):
10
- * 1. 读契约 .design/visual-contract-*.json 的 structure(页面骨架)+ pages(角色绑定)
11
- * 2. 生成列表页 SFC:页头 / 筛选区 / 操作栏 / 表格列 / 状态标签 / 分页,区块数量、
12
- * 列数与宽度档位、控件类型、fixed、对齐、标签变体、图标 path 全部照抄骨架
13
- * 3. 角色 class 一律以字面量写入模板(.role-*),与 ui-roles.css 的生成物一一对应
14
- *
15
- * 红线(形态/内容分离):
16
- * - 业务内容(列名文字、label、数据行、状态文案)一律以 SAMPLE 占位,接入真实业务时替换
17
- * - 本脚本不产生任何视觉判断——视觉形态全部由 ui-tokens/ui-roles/ui-layout.css 承载
18
- * - 同一契约输入必得同一输出(可复现);生成物禁止手改
19
- *
20
- * 范围:
21
- * 只生成 content 区内的页面骨架。壳层(brand/sidebar/header/tabs)由布局宿主承接,
22
- * 见 mapping/<场景>.json 的 layoutHost 与 design-apply 第 4/5 步。
23
- */
24
-
25
- import fs from "node:fs";
26
- import path from "node:path";
27
- import { fileURLToPath } from "node:url";
28
-
29
- const SKILL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
30
-
31
- // ────────────────────────────── 参数
32
-
33
- function parseArgs(argv) {
34
- const out = { project: process.cwd(), out: null, page: null };
35
- for (let i = 0; i < argv.length; i++) {
36
- const a = argv[i];
37
- if (a === "--project" || a === "-p") out.project = path.resolve(argv[++i]);
38
- else if (a === "--out") out.out = argv[++i];
39
- else if (a === "--page") out.page = argv[++i];
40
- else if (a === "--help" || a === "-h") {
41
- console.log(
42
- "用法: node generate-page.mjs --project <项目根> [--out src/views/design-restore] [--page list]"
43
- );
44
- process.exit(0);
45
- }
46
- }
47
- return out;
48
- }
49
-
50
- // ────────────────────────────── 名称转换(与 generate-css.mjs / check-landing.mjs 完全一致)
51
-
52
- function expectedClassName(roleName) {
53
- return (
54
- "role-" +
55
- roleName
56
- .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
57
- .replace(/\./g, "-")
58
- .replace(/_/g, "-")
59
- .toLowerCase()
60
- );
61
- }
62
-
63
- function variantClassName(variantName) {
64
- return variantName
65
- .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
66
- .replace(/_/g, "-")
67
- .toLowerCase();
68
- }
69
-
70
- // ────────────────────────────── 读取契约
71
-
72
- function loadContract(projectRoot) {
73
- const designDir = path.join(projectRoot, ".design");
74
- if (!fs.existsSync(designDir)) {
75
- throw new Error("项目根下没有 .design/ 目录 —— 先由设计侧落盘契约");
76
- }
77
- const names = fs.readdirSync(designDir).filter((n) => /^visual-contract.*\.json$/.test(n));
78
- if (!names.length) throw new Error(".design/ 下没有 visual-contract-*.json");
79
- const contractPath = path.join(designDir, names[0]);
80
- return { contract: JSON.parse(fs.readFileSync(contractPath, "utf-8")), contractPath };
81
- }
82
-
83
- // ────────────────────────────── 骨架归一化
84
-
85
- /** 宽度档位 → px 数值;数字字符串原样;档位未知 → null(列不设宽,打印警告) */
86
- const WIDTH_STEPS = { xs: 80, s: 120, m: 160, l: 200, xl: 280, xxl: 360 };
87
-
88
- function normWidth(raw, warnings, label) {
89
- if (raw == null || raw === "") return null;
90
- if (typeof raw === "number") return raw;
91
- const key = String(raw).trim().toLowerCase();
92
- if (/^\d+$/.test(key)) return parseInt(key, 10);
93
- const px = key.match(/^(\d+)px$/);
94
- if (px) return parseInt(px[1], 10);
95
- if (WIDTH_STEPS[key] != null) return WIDTH_STEPS[key];
96
- warnings.push(`${label}: 宽度档位 "${raw}" 无法识别(可用 ${Object.keys(WIDTH_STEPS).join("/")} 或像素数字),该列不设宽`);
97
- return null;
98
- }
99
-
100
- /** cellKind → 缺省 cellRole(契约列未声明 cellRole 时按 cellKind 兜底,与角色库对齐) */
101
- const DEFAULT_ROLE_BY_KIND = {
102
- index: "table.cell.index",
103
- selection: "table.cell.selection",
104
- amount: "table.cell.amount",
105
- thumb: "table.cell.thumb",
106
- tag: "table.cell.status",
107
- actions: "table.cell.action",
108
- link: "table.cell",
109
- route: "table.cell",
110
- text: "table.cell",
111
- };
112
-
113
- /** 操作列 actions 归一化:["查看","编辑"] / [{text,danger}] / {count:N} / 数字 → [{text,danger}] 或 null */
114
- function normActionItems(raw) {
115
- if (!Array.isArray(raw)) return null;
116
- return raw.map((a, i) => {
117
- const text = typeof a === "string" ? a : (a && (a.text || a.label)) || `操作${i + 1}`;
118
- const danger = typeof a === "object" && a ? a.danger === true : /删除|作废|停用/.test(text);
119
- return { text, danger };
120
- });
121
- }
122
-
123
- /** cellRole → 列型反推(契约结构列常只声明 cellRole 不写 cellKind,如 {cellRole: table.cell.selection}) */
124
- const KIND_BY_CELL_ROLE = {
125
- "table.cell.selection": "selection",
126
- "table.cell.index": "index",
127
- "table.cell.amount": "amount",
128
- "table.cell.thumb": "thumb",
129
- "table.cell.status": "tag",
130
- "table.cell.action": "actions",
131
- };
132
-
133
- function normColumns(rawColumns, warnings) {
134
- return (Array.isArray(rawColumns) ? rawColumns : []).map((c, i) => {
135
- // 列型识别:契约操作列只写 cellRole: table.cell.action + actions 数组(无 cellKind),必须显式推导
136
- const actionItems = normActionItems(c.actions);
137
- const kind = c.cellKind || c.kind || KIND_BY_CELL_ROLE[c.cellRole] || (actionItems ? "actions" : "text");
138
- const roleName = c.cellRole || c.role || DEFAULT_ROLE_BY_KIND[kind] || "table.cell";
139
- if (!c.cellRole && !c.role) {
140
- warnings.push(`列 ${i + 1}: 未声明 cellRole,按列型 "${kind}" 用缺省角色 "${roleName}"`);
141
- }
142
- return {
143
- prop: c.prop || c.key || `col${i + 1}`,
144
- label: c.label || c.sampleLabel || (["selection", "index"].includes(kind) ? "" : `列${i + 1}`),
145
- kind,
146
- roleName,
147
- cls: expectedClassName(roleName),
148
- width: normWidth(c.width ?? c.widthStep, warnings, `列 ${i + 1}`),
149
- fixed: c.fixed === "left" || c.fixed === "right" ? c.fixed : null,
150
- align: ["left", "center", "right"].includes(c.align) ? c.align : null,
151
- tagVariant: typeof c.tagVariant === "string" && c.tagVariant ? c.tagVariant : null,
152
- actionsCount: actionItems
153
- ? actionItems.length
154
- : (c.actions && typeof c.actions === "object" && !Array.isArray(c.actions) && c.actions.count) || 3,
155
- actionItems,
156
- };
157
- });
158
- }
159
-
160
- /** statusMap 归一化:对象 { name: {label, icon, tagVariant} } 或数组 [{name, label, icon, tagVariant}] → [{name, label, icon, tagVariant}] */
161
- function normStatusMap(sm) {
162
- if (!sm) return [];
163
- const entries = [];
164
- if (Array.isArray(sm)) {
165
- for (const it of sm) {
166
- const name = it.name || it.variant || it.key;
167
- if (name)
168
- entries.push({
169
- name: String(name),
170
- label: it.label || String(name),
171
- icon: it.icon || null,
172
- tagVariant: it.tagVariant || null,
173
- });
174
- }
175
- } else if (typeof sm === "object") {
176
- for (const [name, def] of Object.entries(sm)) {
177
- const d = def && typeof def === "object" ? def : {};
178
- entries.push({ name, label: d.label || name, icon: d.icon || null, tagVariant: d.tagVariant || null });
179
- }
180
- }
181
- return entries;
182
- }
183
-
184
- /** icon path 记法 → 内联 SVG 字符串(M... 多段用 | 分隔;rect:x y w h rx;circle:cx cy r) */
185
- function iconSvg(spec, size) {
186
- if (!spec || typeof spec !== "string") return "";
187
- const parts = spec.split("|").map((s) => s.trim()).filter(Boolean);
188
- const inner = parts
189
- .map((p) => {
190
- if (p.startsWith("rect:")) {
191
- const [x, y, w, h, rx] = p.slice(5).trim().split(/\s+/);
192
- return `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx || 0}" />`;
193
- }
194
- if (p.startsWith("circle:")) {
195
- const [cx, cy, r] = p.slice(7).trim().split(/\s+/);
196
- return `<circle cx="${cx}" cy="${cy}" r="${r}" />`;
197
- }
198
- return `<path d="${p}" />`;
199
- })
200
- .join("");
201
- return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
202
- }
203
-
204
- /** 按钮组归一化:{buttons: N} / {buttons: [{text,icon}]} / 数组 → [{text, icon}] */
205
- function normButtons(raw, fallbackText) {
206
- let count = 0;
207
- let items = null;
208
- if (Array.isArray(raw)) items = raw;
209
- else if (raw && Array.isArray(raw.buttons)) items = raw.buttons;
210
- else if (raw && typeof raw === "object" && raw.buttons != null) count = Number(raw.buttons) || 0;
211
- else if (typeof raw === "number") count = raw;
212
- if (items) {
213
- return items.map((b, i) => {
214
- // 契约按钮组常用纯字符串数组(["新增运单","批量导入"]),必须先认字符串
215
- if (typeof b === "string") return { text: b, icon: null };
216
- return {
217
- text: (b && (b.text || b.label)) || `${fallbackText}${i + 1}`,
218
- icon: (b && b.icon) || null,
219
- };
220
- });
221
- }
222
- return Array.from({ length: count }, (_, i) => ({ text: `${fallbackText}${i + 1}`, icon: null }));
223
- }
224
-
225
- /** 筛选项归一化 */
226
- function normFilters(raw, warnings) {
227
- const arr = Array.isArray(raw) ? raw : [];
228
- return arr.map((f, i) => {
229
- const type = f.type || f.control || "input";
230
- if (!["input", "select", "dateRange"].includes(type)) {
231
- warnings.push(`筛选项 ${i + 1}: 控件类型 "${type}" 不在 input/select/dateRange 值域,按 input 处理`);
232
- }
233
- const t = ["input", "select", "dateRange"].includes(type) ? type : "input";
234
- return {
235
- type: t,
236
- label: f.label || f.sampleLabel || `筛选${i + 1}`,
237
- prop: `q${i}`,
238
- placeholder: f.placeholder || null,
239
- collapsed: f.collapsed === true || f.collapsible === true,
240
- roleCls: expectedClassName(`filter.${t}`),
241
- options: Array.isArray(f.options) ? f.options.map(String) : [],
242
- };
243
- });
244
- }
245
-
246
- /** 从 pages.<页型>.nodes 消费绑定:返回指定区域的绑定列表(角色 + variants + for) */
247
- function bindsFor(pages, pageName, areaSuffix) {
248
- const page = (pages && pages[pageName]) || {};
249
- const nodes = Array.isArray(page.nodes) ? page.nodes : [];
250
- return nodes.filter((n) => n && typeof n.in === "string" && n.in.endsWith(areaSuffix));
251
- }
252
-
253
- // ────────────────────────────── SFC 生成
254
-
255
- function genSfc(contract, pageName, warnings) {
256
- const structure = contract.structure || {};
257
- const pageS = structure.page || {};
258
- const L = [];
259
- const push = (...lines) => L.push(...lines);
260
-
261
- // —— 骨架各区块
262
- const pageHeader = pageS.header || null;
263
- const rawFilters = pageS.filters || [];
264
- const filterSection = Array.isArray(rawFilters) ? { items: rawFilters } : rawFilters || null;
265
- const filters = normFilters(filterSection && filterSection.items, warnings);
266
- const hasCollapse = filters.some((f) => f.collapsed);
267
- const toolbar = pageS.toolbar || null;
268
- const table = pageS.table || {};
269
- const columns = normColumns(table.columns, warnings);
270
- const statusEntries = normStatusMap(table.statusMap);
271
- const tagVariants = new Set(statusEntries.map((s) => s.name));
272
- for (const b of bindsFor(contract.pages, pageName, "table.cell")) {
273
- if (b.role && Array.isArray(b.variants)) for (const v of b.variants) tagVariants.add(String(v));
274
- }
275
- const rowCount = (table.rows && table.rows.count) || 8;
276
- const pagination = pageS.pagination || null;
277
-
278
- if (!columns.length) {
279
- throw new Error("契约 structure 缺 page.table.columns —— 列表页没有骨架可重建(契约 §7 第 5 条)");
280
- }
281
-
282
- // —— 按钮角色消费:优先按 nodes 绑定的 for 文本精确匹配;无匹配时按按钮语义回退。
283
- // 不做游标消费(页头/筛选/工具栏共用一套语义映射,先到先得会让页头动作抢走 danger/text 绑定);
284
- // 契约模板常以「主按钮/次按钮/危险按钮」泛称标注 for,文本匹配不上时语义兜底才是主路径
285
- const toolbarBinds = bindsFor(contract.pages, pageName, "toolbar.bar").filter((b) => /^button\./.test(b.role || ""));
286
- const roleClsOf = (role) => ({ cls: expectedClassName(role), primary: /primary/.test(role) });
287
- const semanticRole = (text) =>
288
- /删除|作废|停用/.test(text)
289
- ? "button.danger"
290
- : /查询|搜索|新增|创建|新建|提交|保存/.test(text)
291
- ? "button.primary"
292
- : "button.secondary";
293
- const findBindFor = (text) => {
294
- const idx = toolbarBinds.findIndex((b) => b.for && b.for.includes(text));
295
- if (idx >= 0) return roleClsOf(toolbarBinds[idx].role);
296
- return roleClsOf(semanticRole(text));
297
- };
298
- const searchBtn = findBindFor("查询");
299
- const resetBtn = findBindFor("重置");
300
-
301
- const toolbarLeft = normButtons(toolbar && (toolbar.left ?? toolbar.leftButtons), "操作");
302
- const toolbarRight = normButtons(toolbar && (toolbar.right ?? toolbar.rightButtons), "工具");
303
- const headerActions = pageHeader && pageHeader.actions ? normButtons(pageHeader.actions, "动作") : [];
304
- const headerTitle = (pageHeader && pageHeader.title) || "页面标题(SAMPLE)";
305
- const actionsCol = columns.find((c) => c.kind === "actions");
306
- const actionItems = (actionsCol && actionsCol.actionItems) || [
307
- { text: "编辑", danger: false },
308
- { text: "详情", danger: false },
309
- { text: "删除", danger: true },
310
- ];
311
- const hasSelectionInfo = [...toolbarLeft, ...toolbarRight].some((b) => /已选/.test(b.text) && /项/.test(b.text));
312
- const isSelectionInfo = (text) => /已选/.test(text) && /项/.test(text);
313
- const selectionInfoText = (text) => (text.includes("N") ? text.replace(/N/g, "{{ selectedCount }}") : text);
314
-
315
- // —— TAG 变体类映射与状态图标(字面量落文件,供 roles 比对与 v-html 使用)
316
- const tagCls = "role-tag-status";
317
- // statusMap 键 → CSS 英文变体类:契约以 tagVariant 声明变体(success/warning…),禁止用中文键拼类名
318
- const tagVariantMap = [...tagVariants].map((v) => {
319
- const sm = statusEntries.find((s) => s.name === v);
320
- const variant = (sm && sm.tagVariant) || v;
321
- return ` ${JSON.stringify(v)}: "${tagCls}--${variantClassName(variant)}",`;
322
- });
323
- const statusIconEntries = statusEntries.filter((s) => s.icon);
324
- const statusIconMap = statusIconEntries.map(
325
- (s) => ` ${JSON.stringify(s.name)}: ${JSON.stringify(iconSvg(s.icon, 12))},`
326
- );
327
- const headerIconEntries = headerActions.filter((b) => b.icon);
328
- const toolbarIconEntries = [...toolbarLeft, ...toolbarRight].filter((b) => b.icon);
329
-
330
- // —— 分页组成
331
- const pLayout = pagination
332
- ? [
333
- pagination.total !== false && "total",
334
- pagination.sizes !== false && "sizes",
335
- "prev",
336
- "pager",
337
- "next",
338
- pagination.jumper !== false && "jumper",
339
- ]
340
- .filter(Boolean)
341
- .join(", ")
342
- : null;
343
- const pSizes = (pagination && Array.isArray(pagination.pageSizes) && pagination.pageSizes) || [10, 20, 50, 100];
344
- // 契约分页 total 可能是文案("共 256 条")→ 抽数字作为 SAMPLE 总数
345
- const paginationTotal =
346
- pagination && typeof pagination.total === "string" ? (pagination.total.match(/\d+/) || [])[0] : null;
347
-
348
- // —— mock 行(SAMPLE):契约 rowsSampleOnly 是按列序排列的位置数组,逐行取模填充非结构列
349
- const statusNames = statusEntries.length ? statusEntries.map((s) => s.name) : ["SAMPLE"];
350
- const sampleRowsRaw = (table && Array.isArray(table.rowsSampleOnly) && table.rowsSampleOnly) || [];
351
- const valueCols = columns.filter((c) => !["selection", "index", "actions", "thumb"].includes(c.kind));
352
- const dynamicStatusCol = columns.find((c) => c.kind === "tag" && (!c.tagVariant || c.tagVariant === "status"));
353
- const sampleRows = [];
354
- for (let i = 0; i < rowCount; i++) {
355
- const row = {};
356
- const src = sampleRowsRaw.length ? sampleRowsRaw[i % sampleRowsRaw.length] : null;
357
- let vi = 0;
358
- for (const c of valueCols) {
359
- const v = src ? src[vi++] : null;
360
- const isDynStatus = dynamicStatusCol && c.prop === dynamicStatusCol.prop;
361
- if (isDynStatus && i >= sampleRowsRaw.length) {
362
- row[c.prop] = statusNames[i % statusNames.length];
363
- } else if (v != null) {
364
- row[c.prop] = String(v);
365
- } else if (c.kind === "tag") {
366
- row[c.prop] = c.tagVariant && c.tagVariant !== "status" ? "选项(SAMPLE)" : statusNames[i % statusNames.length];
367
- } else if (c.kind === "amount") {
368
- row[c.prop] = "0.00";
369
- } else {
370
- row[c.prop] = `SAMPLE-${i + 1}`;
371
- }
372
- }
373
- sampleRows.push(row);
374
- }
375
-
376
- // ================= 模板 =================
377
- push("<!-- ============================================================");
378
- push(" 由 vue3-temp-skill/scripts/generate-page.mjs 按契约 structure 生成 —— 禁止手改");
379
- push(` 事实源: .design/visual-contract(页面骨架 + 角色绑定);视觉形态全部来自 ui-*.css`);
380
- push(" 接入方式: 在路由中注册本页;接入真实业务时替换全部 SAMPLE 占位");
381
- push(` (列名/prop/API/权限/字典/数据行——形态保持,内容必须换)`);
382
- push("============================================================ -->");
383
- push('<template>');
384
- push(' <div class="ui-page design-restore-page">');
385
- push(` <section class="role-page-card design-card">`);
386
- // —— 页头
387
- if (pageHeader) {
388
- push(` <header class="role-page-header">`);
389
- push(` <div class="page-header-main">`);
390
- // CSS 期望 .role-page-header-title 是 flex-column 容器,内含 title-text / subtitle-text 子元素(ui-roles.css)
391
- push(` <div class="role-page-header-title">`);
392
- push(` <span class="title-text">${headerTitle}</span>`);
393
- if (pageHeader.subtitle) push(` <span class="subtitle-text">${pageHeader.subtitle}</span>`);
394
- push(` </div>`);
395
- push(` </div>`);
396
- push(` <div class="role-page-header-actions">`);
397
- if (!headerActions.length) {
398
- push(` <!-- 骨架未声明页头动作钮 -->`);
399
- }
400
- for (const b of headerActions) {
401
- const bind = findBindFor(b.text);
402
- // v-html 外层用单引号:JSON.stringify 产出双引号 key,双引号属性会嵌套截断
403
- const iconSpan = b.icon ? `<span class="btn-icon" v-html='HEADER_ICONS[${JSON.stringify(b.text)}]'></span>` : "";
404
- push(
405
- ` <el-button class="${bind.cls || "role-button-secondary"}"${bind.primary ? ` type="primary"` : ""}><!-- SAMPLE 动作钮 -->${iconSpan}${b.text}</el-button>`,
406
- );
407
- }
408
- push(` </div>`);
409
- push(` </header>`);
410
- }
411
- // —— 筛选区
412
- if (filters.length) {
413
- push(` <div class="role-filter-section">`);
414
- filters.forEach((f, i) => {
415
- push(` <div class="role-filter-item"${f.collapsed ? ` v-show="filtersExpanded"` : ""}>`);
416
- push(` <label class="role-filter-label">${f.label}</label>`);
417
- if (f.type === "input") {
418
- push(` <el-input`);
419
- push(` v-model="query.${f.prop}"`);
420
- // 契约 nodes 同时绑定 filter.input 与 input.base:筛选控件需两类同挂(base 段承载边框/高度形态)
421
- push(` class="${f.roleCls} role-input-base"`);
422
- push(` placeholder="${f.placeholder || "请输入(SAMPLE)"}"`);
423
- push(` clearable`);
424
- push(` />`);
425
- } else if (f.type === "select") {
426
- // select 缺省显示:契约 placeholder → 契约 options[0](admin 惯例首选项「全部状态」)→ 通用文案;不用 SAMPLE 占位用户可见 UI
427
- push(` <el-select v-model="query.${f.prop}" class="${f.roleCls} role-select-base" placeholder="${f.placeholder || f.options?.[0] || "请选择"}" clearable>`);
428
- push(` <el-option v-for="o in FILTER_OPTIONS[${i}]" :key="o" :label="o" :value="o" />`);
429
- push(` </el-select>`);
430
- } else {
431
- push(` <el-date-picker`);
432
- push(` v-model="query.${f.prop}"`);
433
- push(` type="daterange"`);
434
- push(` class="${f.roleCls}"`);
435
- push(` range-separator="至"`);
436
- push(` start-placeholder="开始日期"`);
437
- push(` end-placeholder="结束日期"`);
438
- push(` />`);
439
- }
440
- push(` </div>`);
441
- });
442
- push(` <div class="role-filter-actions">`);
443
- push(` <el-button class="${searchBtn.cls || "role-button-primary"}"${searchBtn.primary ? ` type="primary"` : ""} @click="onSearch">查询</el-button>`);
444
- push(` <el-button class="${resetBtn.cls || "role-button-secondary"}"${resetBtn.primary ? ` type="primary"` : ""} @click="onReset">重置</el-button>`);
445
- if (hasCollapse) {
446
- push(` <el-button link type="primary" class="role-button-text" @click="filtersExpanded = !filtersExpanded">`);
447
- push(` {{ filtersExpanded ? "收起" : "展开" }}`);
448
- push(` </el-button>`);
449
- }
450
- push(` </div>`);
451
- push(` </div>`);
452
- }
453
- // —— 操作栏
454
- if (toolbar) {
455
- push(` <div class="role-toolbar-bar">`);
456
- push(` <div class="toolbar-group">`);
457
- for (const b of toolbarLeft) {
458
- if (isSelectionInfo(b.text)) {
459
- push(` <span class="toolbar-selection-info">${selectionInfoText(b.text)}</span>`);
460
- continue;
461
- }
462
- const bind = findBindFor(b.text);
463
- push(` <el-button class="${bind.cls || "role-button-secondary"}"${bind.primary ? ` type="primary"` : ""}>`);
464
- if (b.icon) push(` <span class="btn-icon" v-html='TOOLBAR_ICONS[${JSON.stringify(b.text)}]'></span>`);
465
- push(` ${b.text}`);
466
- push(` </el-button>`);
467
- }
468
- push(` </div>`);
469
- push(` <div class="toolbar-group">`);
470
- for (const b of toolbarRight) {
471
- if (isSelectionInfo(b.text)) {
472
- push(` <span class="toolbar-selection-info">${selectionInfoText(b.text)}</span>`);
473
- continue;
474
- }
475
- const bind = findBindFor(b.text);
476
- push(` <el-button class="${bind.cls || "role-button-secondary"}"${bind.primary ? ` type="primary"` : ""}>`);
477
- if (b.icon) push(` <span class="btn-icon" v-html='TOOLBAR_ICONS[${JSON.stringify(b.text)}]'></span>`);
478
- push(` ${b.text}`);
479
- push(` </el-button>`);
480
- }
481
- push(` </div>`);
482
- push(` </div>`);
483
- }
484
- // —— 表格
485
- push(` <div class="role-table-wrapper">`);
486
- push(` <el-table`);
487
- push(` :data="rows"`);
488
- push(` header-cell-class-name="role-table-header-cell"`);
489
- push(` class="design-table"`);
490
- push(` >`);
491
- for (const c of columns) {
492
- const attrs = [
493
- c.kind === "index" ? `type="index"` : null,
494
- c.kind === "selection" ? `type="selection"` : null,
495
- `prop="${c.kind === "actions" || c.kind === "selection" || c.kind === "index" ? c.prop : c.prop}"`,
496
- `label="${c.label}"`,
497
- c.width != null ? `:width="${c.width}"` : null,
498
- c.fixed ? `fixed="${c.fixed}"` : null,
499
- // 结构列(selection/index/actions)表头变体类:ui-roles.css 有对应 --selection/--index/--action 段(居中等),EP 经 label-class-name 挂到 th
500
- ["selection", "index", "actions"].includes(c.kind) ? `label-class-name="role-table-header-cell--${c.kind === "actions" ? "action" : c.kind}"` : null,
501
- // 数据对齐用契约 align;操作列头居中是基线约定(契约 thead .col-actions text-align:center),契约未声明 align 时缺省——align 只输出一次
502
- c.align || c.kind === "actions" ? `align="${c.align || "center"}"` : null,
503
- `class-name="${c.cls}"`,
504
- ]
505
- .filter(Boolean)
506
- .map((a) => ` ${a}`)
507
- .join("\n");
508
- const needsSlot = ["link", "route", "tag", "actions", "thumb", "amount"].includes(c.kind);
509
- if (!needsSlot) {
510
- push(` <el-table-column`);
511
- push(attrs);
512
- push(` />`);
513
- continue;
514
- }
515
- push(` <el-table-column`);
516
- push(attrs);
517
- push(` >`);
518
- if (c.kind === "link" || c.kind === "route") {
519
- push(` <template #default="{ row }">`);
520
- push(` <!-- SAMPLE 链接;route 形态接入 router.push -->`);
521
- push(` <el-link type="primary" class="role-link">{{ row.${c.prop} }}</el-link>`);
522
- push(` </template>`);
523
- } else if (c.kind === "tag" && c.tagVariant && c.tagVariant !== "status") {
524
- // 静态变体 tag:变体类在契约列上声明(outlined/default…),不经 statusMap 动态解析;
525
- // 形状来自基类 .role-tag-status(adapter 段),变体类只补颜色
526
- push(` <template #default="{ row }">`);
527
- push(` <el-tag class="${tagCls} ${tagCls}--${variantClassName(c.tagVariant)}" effect="light">{{ row.${c.prop} }}</el-tag>`);
528
- push(` </template>`);
529
- } else if (c.kind === "tag") {
530
- push(` <template #default="{ row }">`);
531
- push(` <el-tag`);
532
- push(` class="${tagCls}"`);
533
- push(` :class="TAG_VARIANT_CLASS[row.${c.prop}] || '${tagCls}--default'"`);
534
- push(` effect="light"`);
535
- push(` >`);
536
- if (statusIconEntries.length) {
537
- push(` <span v-if="STATUS_ICONS[row.${c.prop}]" class="tag-icon" v-html="STATUS_ICONS[row.${c.prop}]"></span>`);
538
- }
539
- push(` {{ row.${c.prop} }}`);
540
- push(` </el-tag>`);
541
- push(` </template>`);
542
- } else if (c.kind === "actions") {
543
- push(` <template #default>`);
544
- push(` <!-- SAMPLE 操作项,接入真实业务动作 -->`);
545
- push(` <template v-for="(a, ai) in ACTION_ITEMS.slice(0, ${c.actionsCount})" :key="a.text">`);
546
- push(` <span v-if="ai > 0" class="cell-action-divider"></span>`);
547
- push(` <el-button link :type="a.danger ? 'danger' : 'primary'" class="role-button-text">{{ a.text }}</el-button>`);
548
- push(` </template>`);
549
- push(` </template>`);
550
- } else if (c.kind === "thumb") {
551
- push(` <template #default>`);
552
- push(` <div class="cell-thumb-ph" aria-hidden="true"></div>`);
553
- push(` </template>`);
554
- } else if (c.kind === "amount") {
555
- push(` <template #default="{ row }">`);
556
- push(` <span class="cell-amount">{{ row.${c.prop} }}</span>`);
557
- push(` </template>`);
558
- }
559
- push(` </el-table-column>`);
560
- }
561
- push(` <template #empty>`);
562
- push(` <div class="role-table-empty">暂无数据</div>`);
563
- push(` </template>`);
564
- push(` </el-table>`);
565
- push(` </div>`);
566
- // —— 分页
567
- if (pagination) {
568
- push(` <footer class="role-pagination-bar">`);
569
- push(` <span class="page-total">{{ \`共 \${total} 条\` }}</span>`);
570
- push(` <el-pagination`);
571
- push(` v-model:current-page="pageNo"`);
572
- push(` v-model:page-size="pageSize"`);
573
- push(` :page-sizes="PAGE_SIZES"`);
574
- push(` :total="total"`);
575
- push(` layout="${pLayout}"`);
576
- push(` background`);
577
- push(` />`);
578
- push(` </footer>`);
579
- }
580
- push(` </section>`);
581
- push(` </div>`);
582
- push(`</template>`);
583
- push("");
584
-
585
- // ================= 脚本 =================
586
- push(`<script setup lang="ts">`);
587
- push(`/** SAMPLE:本段全部为演示占位。接入真实业务时替换为项目字段与 API。 */`);
588
- push(`import { reactive, ref } from "vue";`);
589
- push("");
590
- push(`const TITLE = ${JSON.stringify(headerTitle)};`);
591
- if (filters.length) {
592
- push(`const query = reactive({`);
593
- for (const f of filters) {
594
- push(` ${f.prop}: ${f.type === "dateRange" ? "null" : '""'},${f.collapsed ? " // 折叠项" : ""}`);
595
- }
596
- push(`});`);
597
- const selectOpts = filters
598
- .map((f, i) => (f.type === "select" ? ` [${i}]: [${(f.options.length ? f.options : ["选项一(SAMPLE)", "选项二(SAMPLE)"]).map((o) => JSON.stringify(o)).join(", ")}],` : null))
599
- .filter(Boolean);
600
- push(`const FILTER_OPTIONS: Record<number, string[]> = {`);
601
- push(...selectOpts);
602
- push(`};`);
603
- if (hasCollapse) push(`const filtersExpanded = ref(false);`);
604
- }
605
- push(`const rows = ${JSON.stringify(sampleRows, null, 2).split("\n").join("\n")};`);
606
- push(`const ACTION_ITEMS = [`);
607
- for (const a of actionItems) {
608
- push(` { text: ${JSON.stringify(a.text)}, danger: ${a.danger} },`);
609
- }
610
- push(`];`);
611
- if (hasSelectionInfo) push(`const selectedCount = ref(0);`);
612
- if (tagVariants.size) {
613
- push(`const TAG_VARIANT_CLASS: Record<string, string> = {`);
614
- push(...tagVariantMap);
615
- push(`};`);
616
- }
617
- if (statusIconMap.length) {
618
- push(`const STATUS_ICONS: Record<string, string> = {`);
619
- push(...statusIconMap);
620
- push(`};`);
621
- }
622
- if (headerIconEntries.length) {
623
- push(`const HEADER_ICONS: Record<string, string> = {`);
624
- for (const b of headerIconEntries) {
625
- push(` ${JSON.stringify(b.text)}: ${JSON.stringify(iconSvg(b.icon, 14))},`);
626
- }
627
- push(`};`);
628
- }
629
- if (toolbarIconEntries.length) {
630
- push(`const TOOLBAR_ICONS: Record<string, string> = {`);
631
- for (const b of toolbarIconEntries) {
632
- push(` ${JSON.stringify(b.text)}: ${JSON.stringify(iconSvg(b.icon, 14))},`);
633
- }
634
- push(`};`);
635
- }
636
- if (pagination) {
637
- push(`const PAGE_SIZES = ${JSON.stringify(pSizes)};`);
638
- push(`const pageNo = ref(1);`);
639
- push(`const pageSize = ref(${pSizes[0]});`);
640
- push(`const total = ref(${paginationTotal || rowCount});`);
641
- }
642
- push(`function onSearch() {`);
643
- push(` // SAMPLE:接入真实查询 API`);
644
- push(`}`);
645
- push(`function onReset() {`);
646
- if (filters.length) {
647
- push(` Object.assign(query, {`);
648
- for (const f of filters) push(` ${f.prop}: ${f.type === "dateRange" ? "null" : '""'},`);
649
- push(` });`);
650
- }
651
- push(`}`);
652
- push(`</script>`);
653
- push("");
654
-
655
- // ================= 占位样式(仅 mock 视觉,不属于契约形态) =================
656
- push(`<style scoped>`);
657
- push(`/* 仅 SAMPLE 占位样式:mock 缩略块 / 操作分隔线 / 图标对齐。契约视觉全部来自 ui-*.css,禁止在此写主题值 */`);
658
- push(`.cell-thumb-ph {`);
659
- push(` width: 40px;`);
660
- push(` height: 40px;`);
661
- push(` border-radius: 6px;`);
662
- push(` background: #f0f0f0;`);
663
- push(`}`);
664
- push(`.cell-action-divider {`);
665
- push(` display: inline-block;`);
666
- push(` width: 1px;`);
667
- push(` height: 12px;`);
668
- push(` margin: 0 8px;`);
669
- push(` background: #e5e6eb;`);
670
- push(` vertical-align: middle;`);
671
- push(`}`);
672
- push(`.btn-icon,`);
673
- push(`.tag-icon {`);
674
- push(` display: inline-flex;`);
675
- push(` align-items: center;`);
676
- push(` vertical-align: middle;`);
677
- push(`}`);
678
- push(`.tag-icon {`);
679
- push(` margin-right: 4px;`);
680
- push(`}`);
681
- push(`.toolbar-selection-info {`);
682
- push(` align-self: center;`);
683
- push(` font-size: var(--font-size-md);`);
684
- push(` color: var(--color-text-secondary);`);
685
- push(` white-space: nowrap;`);
686
- push(`}`);
687
- push(`</style>`);
688
- push("");
689
-
690
- return L.join("\n");
691
- }
692
-
693
- // ────────────────────────────── 主流程
694
-
695
- function main() {
696
- const args = parseArgs(process.argv.slice(2));
697
- const warnings = [];
698
-
699
- const { contract, contractPath } = loadContract(args.project);
700
-
701
- // 页型:--page > meta.pageTypes[0]
702
- const metaPages = Array.isArray(contract.meta && contract.meta.pageTypes) ? contract.meta.pageTypes : [];
703
- const pageName = args.page || metaPages[0] || "list";
704
- if (!contract.structure || !contract.structure.page) {
705
- console.error("✗ 契约缺 structure(或 structure.page)—— 页面骨架没有事实源");
706
- console.error(" 回设计侧补抽 structure(contract-schema §2.7),重新出契约。");
707
- process.exit(1);
708
- }
709
- if (pageName !== "list") {
710
- console.error(`✗ 页型 "${pageName}" 的脚手架生成器未实现(当前支持 list)`);
711
- console.error(" 该页型请按 design-apply.md 第 6 步.4 手工重建骨架。");
712
- process.exit(1);
713
- }
714
-
715
- const sfc = genSfc(contract, pageName, warnings);
716
-
717
- const outDir = path.join(args.project, args.out || "src/views/design-restore");
718
- fs.mkdirSync(outDir, { recursive: true });
719
- const outFile = path.join(outDir, `${pageName}.vue`);
720
- fs.writeFileSync(outFile, sfc);
721
-
722
- console.log(`契约: ${path.relative(args.project, contractPath)}`);
723
- console.log(`生成: ${path.relative(args.project, outFile)}`);
724
- console.log("骨架已按 structure 重建;角色类已字面量挂载(表格经 class-name / header-cell-class-name)。");
725
- console.log("下一步: 注册路由 → 重跑 check-landing.mjs 看 roles-mounted 覆盖率。");
726
- console.log("红线: 文件内 SAMPLE 占位接入真实业务时必须替换;本文件禁止手改,骨架变更回契约重新生成。");
727
- if (warnings.length) {
728
- console.log("");
729
- console.log("警告(不阻塞,建议回查契约):");
730
- for (const w of [...new Set(warnings)]) console.log(` - ${w}`);
731
- }
732
- }
733
-
734
- main();