@mingfuyun/star-allinone 0.3.1 → 0.3.3

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.
@@ -0,0 +1,118 @@
1
+ const { deref } = require('./spec');
2
+
3
+ const SKIP_COLUMNS = new Set([
4
+ 'created_at',
5
+ 'updated_at',
6
+ 'createTime',
7
+ 'updateTime',
8
+ 'deleted_at',
9
+ ]);
10
+
11
+ const SKIP_FORM = new Set([
12
+ 'id',
13
+ 'pk',
14
+ 'created_at',
15
+ 'updated_at',
16
+ 'createTime',
17
+ 'updateTime',
18
+ 'deleted_at',
19
+ ]);
20
+
21
+ const SKIP_SEARCH = new Set([
22
+ 'page',
23
+ 'page_size',
24
+ 'pageSize',
25
+ 'current',
26
+ 'limit',
27
+ 'offset',
28
+ 'ordering',
29
+ 'sort',
30
+ 'sort_by',
31
+ ]);
32
+
33
+ function schemaType(schema) {
34
+ if (!schema) return undefined;
35
+ if (schema.enum?.length) return 'string';
36
+ const types = Array.isArray(schema.type)
37
+ ? schema.type.filter((item) => item !== 'null')
38
+ : [schema.type];
39
+ return types[0];
40
+ }
41
+
42
+ function fieldFromSchema(spec, name, schema, required) {
43
+ const resolved = deref(spec, schema) || {};
44
+ return {
45
+ name,
46
+ title: resolved.title || name,
47
+ required: Boolean(required),
48
+ type: schemaType(resolved),
49
+ format: resolved.format,
50
+ enum: Array.isArray(resolved.enum) ? resolved.enum : undefined,
51
+ readOnly: Boolean(resolved.readOnly),
52
+ };
53
+ }
54
+
55
+ function schemaFields(spec, schema, options = {}) {
56
+ if (!spec || !schema) return [];
57
+ const skip = options.skip || SKIP_COLUMNS;
58
+ const resolved = deref(spec, schema);
59
+ const properties = resolved?.properties || {};
60
+ const required = new Set(resolved?.required || []);
61
+ const fields = [];
62
+ for (const [name, prop] of Object.entries(properties)) {
63
+ if (skip.has(name)) continue;
64
+ const field = fieldFromSchema(spec, name, prop, required.has(name));
65
+ if (options.forForm && field.readOnly) continue;
66
+ fields.push(field);
67
+ }
68
+ if (typeof options.limit === 'number') return fields.slice(0, options.limit);
69
+ return fields;
70
+ }
71
+
72
+ function formComponent(field) {
73
+ if (field.enum?.length) return 'ProFormSelect';
74
+ if (field.type === 'boolean') return 'ProFormSwitch';
75
+ if (field.type === 'integer' || field.type === 'number') return 'ProFormDigit';
76
+ if (field.format === 'date-time') return 'ProFormDateTimePicker';
77
+ if (field.format === 'date') return 'ProFormDatePicker';
78
+ if (/desc|note|remark|comment|content|message/i.test(field.name)) return 'ProFormTextArea';
79
+ return 'ProFormText';
80
+ }
81
+
82
+ function columnValueType(field) {
83
+ if (field.enum?.length) return 'select';
84
+ if (field.type === 'boolean') return 'select';
85
+ if (field.type === 'integer' || field.type === 'number') return 'digit';
86
+ if (field.format === 'date-time') return 'dateTime';
87
+ if (field.format === 'date') return 'date';
88
+ return undefined;
89
+ }
90
+
91
+ function fallbackFields(itemTypeSource = '') {
92
+ const names = [...itemTypeSource.matchAll(/^\s+(\w+)\??:/gm)]
93
+ .map((match) => match[1])
94
+ .filter((name) => !SKIP_COLUMNS.has(name));
95
+ return names.slice(0, 6).map((name) => ({
96
+ name,
97
+ title: name,
98
+ required: false,
99
+ type: 'string',
100
+ }));
101
+ }
102
+
103
+ function querySearchNames(queryParams = []) {
104
+ return queryParams
105
+ .map((param) => param?.name)
106
+ .filter((name) => name && !SKIP_SEARCH.has(name));
107
+ }
108
+
109
+ module.exports = {
110
+ SKIP_COLUMNS,
111
+ SKIP_FORM,
112
+ SKIP_SEARCH,
113
+ schemaFields,
114
+ formComponent,
115
+ columnValueType,
116
+ fallbackFields,
117
+ querySearchNames,
118
+ };
@@ -3,6 +3,7 @@ const os = require('os');
3
3
  const path = require('path');
4
4
  const { generateService } = require('../service');
5
5
  const { generatePage } = require('../page');
6
+ const { runDoctor } = require('../doctor');
6
7
 
7
8
  function assert(condition, message) {
8
9
  if (!condition) throw new Error(message);
@@ -15,6 +16,7 @@ function makeProject() {
15
16
  JSON.stringify(
16
17
  {
17
18
  project: 'fixture',
19
+ publicSkillPrefix: 'shared-',
18
20
  serviceDir: 'src/services',
19
21
  openapi: 'openapi.json',
20
22
  },
@@ -45,10 +47,41 @@ function makeProject() {
45
47
  `,
46
48
  );
47
49
  fs.mkdirSync(path.join(root, 'src/components/CommonTable'), { recursive: true });
50
+ fs.writeFileSync(path.join(root, 'src/components/CommonTable/MFProTable.tsx'), 'export default function MFProTable() { return null; }\n');
48
51
  fs.mkdirSync(path.join(root, 'src/pages'), { recursive: true });
52
+ fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({
53
+ dependencies: { '@mingfuyun/star-allinone': '^0.3.2' },
54
+ scripts: { typecheck: 'tsc --noEmit' },
55
+ }, null, 2));
49
56
  return root;
50
57
  }
51
58
 
59
+ function writeContainerConfig(root) {
60
+ const dir = path.join(root, 'src/components/Page/Container');
61
+ fs.mkdirSync(dir, { recursive: true });
62
+ fs.writeFileSync(
63
+ path.join(dir, 'config.tsx'),
64
+ `export default {
65
+ '/existing': [
66
+ { key: 'tab-existing', label: 'Existing', children: <div /> },
67
+ ],
68
+ };
69
+ `,
70
+ );
71
+ }
72
+
73
+ function generateWidgetsPage(root, feature = 'Widgets', route = '/widgets') {
74
+ generatePage(root, {
75
+ feature,
76
+ route,
77
+ title: '组件管理',
78
+ listOperation: 'GET /api/v1/widgets/',
79
+ createOperation: 'POST /api/v1/widgets/',
80
+ updateOperation: 'PATCH /api/v1/widgets/{id}/',
81
+ deleteOperation: 'DELETE /api/v1/widgets/{id}/',
82
+ });
83
+ }
84
+
52
85
  function main() {
53
86
  const root = makeProject();
54
87
  const first = generateService(root, {
@@ -78,21 +111,53 @@ function main() {
78
111
  assert(patched.includes('widgetsPartialUpdate'), patched);
79
112
  assert(patched.includes('MFRequest.patch<WIDGETS_API.Widget>'), patched);
80
113
 
81
- generatePage(root, {
82
- feature: 'Widgets',
83
- route: '/widgets',
84
- title: '组件管理',
85
- listOperation: 'GET /api/v1/widgets/',
86
- createOperation: 'POST /api/v1/widgets/',
87
- updateOperation: 'PATCH /api/v1/widgets/{id}/',
88
- deleteOperation: 'DELETE /api/v1/widgets/{id}/',
89
- });
114
+ generateWidgetsPage(root);
90
115
  const page = fs.readFileSync(path.join(root, 'src/pages/Widgets/index.tsx'), 'utf8');
91
116
  assert(page.includes('Service.widgetsList'), page);
92
- assert(page.includes("rowKey=\"id\""), page);
117
+ assert(page.includes('rowKey="id"'), page);
118
+ const columns = fs.readFileSync(path.join(root, 'src/pages/Widgets/const.tsx'), 'utf8');
119
+ assert(columns.includes("dataIndex: 'name'"), columns);
120
+ assert(columns.includes("dataIndex: 'kind'"), columns);
121
+ assert(columns.includes("valueType: 'select'"), columns);
122
+ assert(columns.includes("valueType: 'digit'"), columns);
123
+ const drawer = fs.readFileSync(path.join(root, 'src/pages/Widgets/components/WidgetsDrawer.tsx'), 'utf8');
124
+ assert(drawer.includes('ProFormSelect'), drawer);
125
+ assert(drawer.includes("name=\"status\""), drawer);
126
+ assert(drawer.includes("name=\"name\""), drawer);
127
+ assert(drawer.includes('required: true'), drawer);
93
128
  const route = fs.readFileSync(path.join(root, 'config/route.ts'), 'utf8');
94
129
  assert(route.includes("path: '/widgets'"), route);
130
+
131
+ const tabRoot = makeProject();
132
+ writeContainerConfig(tabRoot);
133
+ generateService(tabRoot, { operation: 'GET /api/v1/widgets/' });
134
+ generateWidgetsPage(tabRoot);
135
+ const shell = fs.readFileSync(path.join(tabRoot, 'src/pages/Widgets/index.tsx'), 'utf8');
136
+ assert(shell.includes("import Container from '@/components/Page/Container'"), shell);
137
+ const list = fs.readFileSync(path.join(tabRoot, 'src/pages/Widgets/components/List.tsx'), 'utf8');
138
+ assert(list.includes('Service.widgetsList'), list);
139
+ const tabConfig = fs.readFileSync(path.join(tabRoot, 'src/components/Page/Container/config.tsx'), 'utf8');
140
+ assert(tabConfig.includes("'/widgets'"), tabConfig);
141
+ assert(tabConfig.includes("@/pages/Widgets/components/List"), tabConfig);
142
+
143
+ const healthy = runDoctor(root);
144
+ assert(healthy.some((item) => item.message.includes('MFProTable')), JSON.stringify(healthy));
145
+ assert(!healthy.some((item) => item.level === 'error' && item.message.includes('MFRequest')), JSON.stringify(healthy));
146
+
147
+ fs.mkdirSync(path.join(root, 'src/pages/Bad'), { recursive: true });
148
+ fs.writeFileSync(
149
+ path.join(root, 'src/pages/Bad/index.tsx'),
150
+ "import { MFTablePro } from '@mingfuyun/star-allinone';\nimport CustomDrawer from '@/components/CustomDrawer';\n",
151
+ );
152
+ fs.mkdirSync(path.join(root, 'src/components/CustomDrawer'), { recursive: true });
153
+ fs.writeFileSync(path.join(root, 'src/components/CustomDrawer/index.tsx'), 'export default function CustomDrawer() { return null; }\n');
154
+ const sick = runDoctor(root);
155
+ assert(sick.some((item) => item.level === 'error' && /MFTablePro/.test(item.message)), JSON.stringify(sick));
156
+ assert(sick.some((item) => item.level === 'error' && /Local overlay/.test(item.message)), JSON.stringify(sick));
157
+
95
158
  fs.rmSync(root, { recursive: true, force: true });
159
+ fs.rmSync(tabRoot, { recursive: true, force: true });
160
+ process.exitCode = 0;
96
161
  console.log('codegen fixtures passed');
97
162
  }
98
163
 
@@ -165,6 +165,7 @@
165
165
  "properties": {
166
166
  "id": { "type": "string" },
167
167
  "name": { "type": "string" },
168
+ "kind": { "type": "string", "enum": ["gadget", "tool"] },
168
169
  "status": { "type": "string", "nullable": true },
169
170
  "count": { "type": "integer" }
170
171
  }
@@ -174,7 +175,7 @@
174
175
  "required": ["name"],
175
176
  "properties": {
176
177
  "name": { "type": "string" },
177
- "status": { "type": "string" }
178
+ "status": { "type": "string", "enum": ["active", "inactive"] }
178
179
  }
179
180
  },
180
181
  "WidgetList": {
@@ -1,8 +1,16 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { toPascal, parseOperationSelector } = require('./names');
3
+ const { toPascal } = require('./names');
4
4
  const { generateService } = require('./service');
5
5
  const { loadProject, writeFile } = require('./project');
6
+ const {
7
+ schemaFields,
8
+ formComponent,
9
+ columnValueType,
10
+ fallbackFields,
11
+ SKIP_FORM,
12
+ querySearchNames,
13
+ } = require('./fields');
6
14
 
7
15
  function detectLayout(root) {
8
16
  const hasPageContainer = fs.existsSync(path.join(root, 'src/components/PageContainer/index.tsx'));
@@ -28,30 +36,39 @@ function featureNames(options) {
28
36
  };
29
37
  }
30
38
 
31
- function columnFields(itemTypeSource = '') {
32
- const fields = [...itemTypeSource.matchAll(/^\s+(\w+)\??:/gm)].map((match) => match[1]);
33
- const preferred = fields.filter((name) => !['created_at', 'updated_at', 'createTime', 'updateTime'].includes(name));
34
- return (preferred.length ? preferred : fields).slice(0, 6);
39
+ function searchFieldNames(fields) {
40
+ return fields
41
+ .filter((field) => field.name !== 'id' && (field.type === 'string' || !field.type))
42
+ .slice(0, 2)
43
+ .map((field) => field.name);
35
44
  }
36
45
 
37
- function emitColumns(names, itemType, fields, permissions) {
38
- const columns = fields.map((field, index) => {
39
- const search = index < 2;
46
+ function emitColumns(names, itemType, fields, permissions, queryNames) {
47
+ const columnFields = fields.length ? fields : [{ name: 'id', title: 'ID', type: 'string' }];
48
+ const searchable = new Set(
49
+ queryNames?.length
50
+ ? columnFields.filter((field) => queryNames.includes(field.name)).map((field) => field.name)
51
+ : searchFieldNames(columnFields),
52
+ );
53
+ const columns = columnFields.map((field) => {
54
+ const search = searchable.has(field.name);
40
55
  return ` {
41
- title: '${field}',
42
- dataIndex: '${field}',
43
- key: '${field}',
56
+ title: ${JSON.stringify(field.title)},
57
+ dataIndex: '${field.name}',
58
+ key: '${field.name}',
44
59
  hideInSearch: ${search ? 'false' : 'true'},
45
- ellipsis: true,
46
- render: (_, record) => record.${field} ?? '-',
60
+ ellipsis: true,${valueTypeBlock(field)}${enumBlock(field)}
61
+ render: (_, record) => record.${field.name} ?? '-',
47
62
  }`;
48
63
  });
49
- if (!columns.length) {
64
+ for (const name of queryNames || []) {
65
+ if (columnFields.some((field) => field.name === name)) continue;
50
66
  columns.push(` {
51
- title: 'ID',
52
- dataIndex: 'id',
53
- key: 'id',
54
- hideInSearch: true,
67
+ title: ${JSON.stringify(name)},
68
+ dataIndex: '${name}',
69
+ key: '${name}',
70
+ hideInTable: true,
71
+ hideInSearch: false,
55
72
  }`);
56
73
  }
57
74
  const permCreate = permissions.create ? ` permission: '${permissions.create}',\n` : '';
@@ -105,14 +122,47 @@ ${permCreate} onClick: handleCreate,
105
122
  `;
106
123
  }
107
124
 
108
- function emitIndex(names, layout, types, fns, pagination) {
109
- const pageImport = layout.hasPageContainer
125
+ function valueTypeBlock(field) {
126
+ const valueType = columnValueType(field);
127
+ return valueType ? `\n valueType: '${valueType}',` : '';
128
+ }
129
+
130
+ function enumBlock(field) {
131
+ if (field.enum?.length) {
132
+ const entries = field.enum
133
+ .map((value) => ` ${JSON.stringify(String(value))}: { text: ${JSON.stringify(String(value))} }`)
134
+ .join(',\n');
135
+ return `\n valueEnum: {\n${entries},\n },`;
136
+ }
137
+ if (field.type === 'boolean') {
138
+ return `\n valueEnum: { true: { text: '是' }, false: { text: '否' } },`;
139
+ }
140
+ return '';
141
+ }
142
+
143
+ function emitContainerIndex() {
144
+ return `import Container from '@/components/Page/Container';
145
+
146
+ export default () => {
147
+ return <Container />;
148
+ };
149
+ `;
150
+ }
151
+
152
+ function emitIndex(names, layout, types, fns, pagination, variant = 'page') {
153
+ const isList = variant === 'list';
154
+ const usePageContainer = layout.hasPageContainer && !layout.hasContainerConfig && !isList;
155
+ const pageImport = usePageContainer
110
156
  ? `import PageContainer from '@/components/PageContainer';\n`
111
157
  : '';
112
- const open = layout.hasPageContainer
158
+ const overlayDir = isList ? './' : './components/';
159
+ const constImport = isList ? '../const' : './const';
160
+ const stylesImport = isList ? '../index.less' : './index.less';
161
+ const exportName = isList ? 'List' : names.leaf;
162
+ const open = usePageContainer
113
163
  ? ` <PageContainer header={{ title: undefined }}>`
114
164
  : ' <>';
115
- const close = layout.hasPageContainer ? ` </PageContainer>` : ' </>';
165
+ const close = usePageContainer ? ` </PageContainer>` : ' </>';
116
166
  const listAccess = pagination === 'results-count'
117
167
  ? 'list: data?.results || [],\n total: data?.count || 0,'
118
168
  : 'list: data?.list || [],\n total: data?.total || 0,';
@@ -130,14 +180,14 @@ import { message } from 'antd';
130
180
  import { useCallback, useRef } from 'react';
131
181
  import ${names.confirm}, {
132
182
  type ${names.confirm}Ref,
133
- } from './components/${names.confirm}';
183
+ } from '${overlayDir}${names.confirm}';
134
184
  import ${names.drawer}, {
135
185
  type ${names.drawer}Ref,
136
- } from './components/${names.drawer}';
137
- import { columns, getActionColumn, getToolBarRender } from './const';
138
- import styles from './index.less';
186
+ } from '${overlayDir}${names.drawer}';
187
+ import { columns, getActionColumn, getToolBarRender } from '${constImport}';
188
+ import styles from '${stylesImport}';
139
189
 
140
- export default function ${names.leaf}() {
190
+ export default function ${exportName}() {
141
191
  const tableRef = useRef<MFTableRef>(null);
142
192
  const drawerRef = useRef<${names.drawer}Ref>(null);
143
193
  const confirmRef = useRef<${names.confirm}Ref>(null);
@@ -224,8 +274,62 @@ function emitLess(layout) {
224
274
  `;
225
275
  }
226
276
 
227
- function emitDrawer(names, types, fns) {
277
+ function emitFormItem(field) {
278
+ const component = formComponent(field);
279
+ const label = JSON.stringify(field.title);
280
+ const requiredVerb = component === 'ProFormSelect' ? '请选择' : '请输入';
281
+ const required = field.required
282
+ ? `\n rules={[{ required: true, message: ${JSON.stringify(`${requiredVerb}${field.title}`)} }]}`
283
+ : '';
284
+ if (component === 'ProFormSelect') {
285
+ const options = (field.enum || [])
286
+ .map((value) => `{ label: ${JSON.stringify(String(value))}, value: ${JSON.stringify(value)} }`)
287
+ .join(', ');
288
+ return ` <ProFormSelect
289
+ name="${field.name}"
290
+ label={${label}}
291
+ options={[${options}]}${required}
292
+ />`;
293
+ }
294
+ if (component === 'ProFormSwitch') {
295
+ return ` <ProFormSwitch
296
+ name="${field.name}"
297
+ label={${label}}
298
+ />`;
299
+ }
300
+ if (component === 'ProFormDigit') {
301
+ return ` <ProFormDigit
302
+ name="${field.name}"
303
+ label={${label}}
304
+ placeholder={${JSON.stringify(`请输入${field.title}`)}}${required}
305
+ />`;
306
+ }
307
+ if (component === 'ProFormDateTimePicker' || component === 'ProFormDatePicker') {
308
+ return ` <${component}
309
+ name="${field.name}"
310
+ label={${label}}${required}
311
+ />`;
312
+ }
313
+ if (component === 'ProFormTextArea') {
314
+ return ` <ProFormTextArea
315
+ name="${field.name}"
316
+ label={${label}}
317
+ placeholder={${JSON.stringify(`请输入${field.title}`)}}${required}
318
+ />`;
319
+ }
320
+ return ` <ProFormText
321
+ name="${field.name}"
322
+ label={${label}}
323
+ placeholder={${JSON.stringify(`请输入${field.title}`)}}${required}
324
+ />`;
325
+ }
326
+
327
+ function emitDrawer(names, types, fns, formFields) {
228
328
  const itemType = types.itemType || 'Record<string, unknown>';
329
+ const fields = formFields.length
330
+ ? formFields
331
+ : [{ name: 'name', title: '名称', required: true, type: 'string' }];
332
+ const components = ['ProForm', ...new Set(fields.map(formComponent))];
229
333
  const createCall = fns.create
230
334
  ? ` await Service.${fns.create}(values);`
231
335
  : ' // TODO: call create service';
@@ -233,7 +337,7 @@ function emitDrawer(names, types, fns) {
233
337
  ? ` await Service.${fns.update}(record.id, values);`
234
338
  : ' // TODO: call update service';
235
339
  return `import Service from '@/services';
236
- import { ProForm, ProFormText } from '@ant-design/pro-components';
340
+ import { ${components.join(', ')} } from '@ant-design/pro-components';
237
341
  import { CustomDrawer, HocModal } from '@mingfuyun/star-allinone';
238
342
  import { Form, message } from 'antd';
239
343
  import { useEffect, useState } from 'react';
@@ -303,12 +407,7 @@ ${createCall}
303
407
  layout="horizontal"
304
408
  submitter={false}
305
409
  >
306
- <ProFormText
307
- name="name"
308
- label="名称"
309
- placeholder="请输入名称"
310
- rules={[{ required: true, message: '请输入名称' }]}
311
- />
410
+ ${fields.map(emitFormItem).join('\n')}
312
411
  </ProForm>
313
412
  </CustomDrawer>
314
413
  );
@@ -407,6 +506,49 @@ function patchRoute(root, names, dryRun) {
407
506
  writeFile(file, next, dryRun);
408
507
  }
409
508
 
509
+ function insertImport(source, line) {
510
+ if (source.includes(line.trim())) return source;
511
+ const matches = [...source.matchAll(/^import .*;\n/gm)];
512
+ if (!matches.length) return `${line}${source}`;
513
+ const last = matches[matches.length - 1];
514
+ const index = last.index + last[0].length;
515
+ return `${source.slice(0, index)}${line}${source.slice(index)}`;
516
+ }
517
+
518
+ function patchContainerConfig(root, names, dryRun) {
519
+ const file = path.join(root, 'src/components/Page/Container/config.tsx');
520
+ if (!fs.existsSync(file)) return false;
521
+ let current = fs.readFileSync(file, 'utf8');
522
+ const importPath = `@/pages/${names.featurePath}/components/List`;
523
+ const hasRoute = current.includes(`'${names.route}'`) || current.includes(`"${names.route}"`);
524
+ if (current.includes(importPath) && hasRoute) {
525
+ console.log(`tab container already has ${names.route}`);
526
+ return false;
527
+ }
528
+ current = insertImport(current, `import ${names.leaf} from '${importPath}';\n`);
529
+ if (!hasRoute) {
530
+ const entry = ` '${names.route}': [
531
+ {
532
+ key: 'tab-${names.leaf}',
533
+ label: ${JSON.stringify(names.title)},
534
+ children: <${names.leaf} />,
535
+ },
536
+ ],
537
+ `;
538
+ if (/export default \{\r?\n/.test(current)) {
539
+ current = current.replace(/export default \{\r?\n/, (match) => `${match}${entry}`);
540
+ } else if (current.includes('export default {')) {
541
+ current = current.replace('export default {', `export default {\n${entry}`);
542
+ } else {
543
+ console.log('Could not patch src/components/Page/Container/config.tsx; register the tab manually.');
544
+ return false;
545
+ }
546
+ }
547
+ writeFile(file, current, dryRun);
548
+ console.log(`${dryRun ? '[dry-run] ' : ''}registered tab ${names.route} in src/components/Page/Container/config.tsx`);
549
+ return true;
550
+ }
551
+
410
552
  function generatePage(cwd, options) {
411
553
  const operations = [
412
554
  ['list', options.listOperation],
@@ -429,6 +571,9 @@ function generatePage(cwd, options) {
429
571
  path.join(pageDir, 'components', `${names.drawer}.tsx`),
430
572
  path.join(pageDir, 'components', `${names.confirm}.tsx`),
431
573
  ];
574
+ if (layout.hasContainerConfig) {
575
+ files.push(path.join(pageDir, 'components', 'List.tsx'));
576
+ }
432
577
  const exists = files.filter((file) => fs.existsSync(file));
433
578
  if (exists.length) {
434
579
  throw new Error(`Refusing to overwrite existing page files:\n${exists.map((file) => path.relative(project.root, file)).join('\n')}`);
@@ -438,6 +583,7 @@ function generatePage(cwd, options) {
438
583
  let types = { itemType: `${toPascal(names.leaf)}_API.${names.leaf}Item`, queryType: undefined };
439
584
  let pagination = 'list-total';
440
585
  let itemSource = '';
586
+ const operationByKey = {};
441
587
 
442
588
  for (const [key, selector] of operations) {
443
589
  const result = generateService(cwd, {
@@ -446,6 +592,7 @@ function generatePage(cwd, options) {
446
592
  mockFile: false,
447
593
  });
448
594
  fns[key] = result.fn;
595
+ operationByKey[key] = result.operation;
449
596
  if (key === 'list') {
450
597
  types = {
451
598
  itemType: result.itemType || types.itemType,
@@ -459,25 +606,37 @@ function generatePage(cwd, options) {
459
606
  }
460
607
  }
461
608
 
462
- const fields = columnFields(itemSource);
609
+ const spec = project.spec;
610
+ let fields = schemaFields(spec, operationByKey.list?.itemSchema, { limit: 8 });
611
+ if (!fields.length) fields = fallbackFields(itemSource);
612
+ const formFields = schemaFields(
613
+ spec,
614
+ operationByKey.create?.bodySchema || operationByKey.update?.bodySchema || operationByKey.list?.itemSchema,
615
+ { skip: SKIP_FORM, forForm: true },
616
+ );
463
617
  const permissions = {
464
618
  create: options.permissionCreate,
465
619
  edit: options.permissionEdit,
466
620
  delete: options.permissionDelete,
467
621
  };
468
622
 
469
- writeFile(files[0], emitIndex(names, layout, types, fns, pagination), project.dryRun);
623
+ const queryNames = querySearchNames(operationByKey.list?.queryParams);
470
624
  writeFile(files[1], emitLess(layout), project.dryRun);
471
- writeFile(files[2], emitColumns(names, types.itemType, fields, permissions), project.dryRun);
472
- writeFile(files[3], emitDrawer(names, types, fns), project.dryRun);
625
+ writeFile(files[2], emitColumns(names, types.itemType, fields, permissions, queryNames), project.dryRun);
626
+ writeFile(files[3], emitDrawer(names, types, fns, formFields), project.dryRun);
473
627
  writeFile(files[4], emitConfirm(names), project.dryRun);
474
- patchRoute(project.root, names, project.dryRun);
475
-
476
628
  if (layout.hasContainerConfig) {
477
- console.log(`starscope-style tab container detected. Register ${names.featurePath} in src/components/Page/Container/config.tsx if this page should appear as a tab.`);
629
+ writeFile(files[0], emitContainerIndex(), project.dryRun);
630
+ writeFile(files[5], emitIndex(names, layout, types, fns, pagination, 'list'), project.dryRun);
631
+ patchRoute(project.root, names, project.dryRun);
632
+ patchContainerConfig(project.root, names, project.dryRun);
633
+ } else {
634
+ writeFile(files[0], emitIndex(names, layout, types, fns, pagination), project.dryRun);
635
+ patchRoute(project.root, names, project.dryRun);
478
636
  }
637
+
479
638
  console.log(`${project.dryRun ? '[dry-run] ' : ''}generated page ${names.route} -> src/pages/${names.featurePath}`);
480
639
  return names;
481
640
  }
482
641
 
483
- module.exports = { generatePage, featureNames };
642
+ module.exports = { generatePage, featureNames, patchContainerConfig };
package/skills/README.md CHANGED
@@ -46,11 +46,13 @@ Cursor / Codex 的常驻说明只放仓库根 `AGENTS.md`(模板见 `examples/
46
46
  ├── design-to-page/ # 页面;标准列表内部跑 gen page
47
47
  ├── review-and-submit/ # 审查并提交;步骤在 references/
48
48
  ├── publish-test/ # 测发
49
- ├── project-conventions/ # architecture / review-checklist / quality-checklist
49
+ ├── project-conventions/ # architecture / component-contract / mfprotable / review-checklist / quality-checklist
50
50
  └── i-have-adhd/ # 用户输出格式
51
51
  ```
52
52
 
53
- 公共入口写死两边已对齐的硬规则,例如:页面走 `MFRequest`、DTO 放 namespace typings、`MFProTable` + `ProForm`、弹层用 `BaseModal` / `CustomDrawer` / `HocModal`、`await` 必须 try/catch、禁止无说明的 `any`。
53
+ 公共入口写死两边已对齐的硬规则,例如:页面走 `MFRequest`、DTO 放 namespace typings、表格用业务仓 `@/components/CommonTable/MFProTable`(不要从包里找 `MFTablePro`)、`ProForm`、弹层从 `@mingfuyun/star-allinone` 导入 `BaseModal` / `CustomDrawer` / `HocModal`、`await` 必须 try/catch、禁止无说明的 `any`、React 18。
54
+
55
+ 接入或升级后跑 `pnpm exec star-allinone doctor`。
54
56
 
55
57
  业务项目的 `.agents/project/` 只写差异:路径、权限体系、OpenAPI 位置、领域特例。项目文件可以补充,但不能削弱公共硬规则。
56
58
 
@@ -70,6 +72,7 @@ npm 包中的 skills/shared
70
72
 
71
73
  ```bash
72
74
  pnpm exec star-allinone skills sync
75
+ pnpm exec star-allinone doctor
73
76
  ```
74
77
 
75
78
  同步会整体替换 `shared-*` 目录,并删除包中已不存在的过期 `shared-*`,但不会改动 `custom-*`。
@@ -132,7 +135,7 @@ git commit -m "chore(skill): 更新公共 skill"
132
135
  | `shared-design-to-page` | SKILL + `page-conventions.md`、`overlays.md`、`list-page-template.md` | 页面分层、标准列表 CLI、弹层合同 |
133
136
  | `shared-review-and-submit` | SKILL + `impact-analysis.md`、`verify.md`、`ai-review.md`、`commit.md` | 影响分析 → 验证 → 确认后提交 |
134
137
  | `shared-publish-test` | SKILL + `scripts/publish-current-branch.sh` | 云效测发;只读 `publishPipelineId` |
135
- | (非入口)`shared-project-conventions` | `architecture.md`、`review-checklist.md`、`quality-checklist.md` | 架构与质量清单 |
138
+ | (非入口)`shared-project-conventions` | `architecture.md`、`mfprotable.md`、`component-contract.md`、`review-checklist.md`、`quality-checklist.md` | 架构、导入合同、质量清单 |
136
139
  | (非入口)`shared-i-have-adhd` | `i-have-adhd.md` | 用户输出格式 |
137
140
 
138
141
  以下内容**不**写进公共 skill 源码:
@@ -141,6 +144,16 @@ git commit -m "chore(skill): 更新公共 skill"
141
144
  - OpenAPI 快照:业务仓库数据文件
142
145
  - 真正的业务特例:权限常量名、IP 校验、设备/节点领域规则
143
146
 
147
+ ## 云效门禁
148
+
149
+ 业务仓通常没有 pipeline YAML。在云效构建控制台显式增加:
150
+
151
+ ```bash
152
+ pnpm typecheck && pnpm lint
153
+ ```
154
+
155
+ 再跑现有 `pnpm build`。`pnpm test` 若实际是 `max dev`(测网开发服务),改名为 `start:test` / `dev:test`,避免 Agent 当成单测。
156
+
144
157
  ## 最终推荐流程
145
158
 
146
159
  ```text