openyida 2026.9.9 → 2026.9.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.
Files changed (30) hide show
  1. package/lib/app/create-form/args.js +4 -0
  2. package/lib/app/create-form/batch.js +193 -13
  3. package/lib/app/create-form.js +45 -14
  4. package/lib/app/update-app.js +17 -2
  5. package/lib/core/cli-error.js +10 -1
  6. package/lib/core/command-manifest.js +2 -2
  7. package/lib/design-plan/init.js +90 -12
  8. package/lib/design-plan/materialize.js +12 -6
  9. package/lib/design-plan/normalize.js +88 -1
  10. package/package.json +1 -1
  11. package/yida-skills/skills/yida-app/SKILL.md +11 -2
  12. package/yida-skills/skills/yida-app/workflow/parallel-work.md +3 -1
  13. package/yida-skills/skills/yida-app/workflow/plan/step-4-deliver.md +6 -4
  14. package/yida-skills/skills/yida-app/workflow/plan/workflow.md +8 -5
  15. package/yida-skills/skills/yida-app/workflow/step-2-design.md +5 -1
  16. package/yida-skills/skills/yida-app/workflow/step-3-create-or-reuse-app.md +21 -1
  17. package/yida-skills/skills/yida-app/workflow/step-4-forms-processes.md +3 -1
  18. package/yida-skills/skills/yida-create-app/SKILL.md +4 -4
  19. package/yida-skills/skills/yida-create-form-page/SKILL.md +52 -1
  20. package/yida-skills/skills/yida-create-form-page/references/batch-forms.md +18 -2
  21. package/yida-skills/skills/yida-design/references/ask-human-interaction-contract.md +4 -4
  22. package/yida-skills/skills/yida-design/references/design-mode.md +1 -1
  23. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/references/build-plan-compact-schema.md +3 -1
  24. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/references/build-plan-schema.md +1 -1
  25. package/yida-skills/skills/yida-design/workflow/step-4-wireframe-interaction.md +1 -1
  26. package/yida-skills/skills/yida-nav-shell/references/nav-shell-patterns.md +1 -1
  27. package/yida-skills/skills/yida-prd/workflow/output-prd.md +2 -2
  28. package/yida-skills/skills/yida-prd/workflow/plan-business.md +3 -1
  29. package/yida-skills/skills/yida-requirement-analysis/SKILL.md +9 -8
  30. package/yida-skills/skills/yida-requirement-analysis/workflow/prepare-brief.md +44 -13
@@ -143,8 +143,10 @@ function createParseArgs(dependencies) {
143
143
  if (args.includes('--help') || args.includes('-h')) {
144
144
  usage(t('create_form.usage_create_short'));
145
145
  hint(t('create_form.usage_update_short'));
146
+ hint(' batch: openyida create-form batch <appType> <plan.json> [--concurrency 1..4] [--json]');
146
147
  hint(t('create_form.example_create'));
147
148
  hint(t('create_form.example_update'));
149
+ hint('Example: openyida create-form batch "APP_XXX" .cache/openyida/forms/forms.json --json');
148
150
  return {
149
151
  mode: 'help',
150
152
  help: true,
@@ -347,8 +349,10 @@ function createParseArgs(dependencies) {
347
349
 
348
350
  usage(t('create_form.usage_create_short'));
349
351
  hint(t('create_form.usage_update_short'));
352
+ hint(' batch: openyida create-form batch <appType> <plan.json> [--concurrency 1..4] [--json]');
350
353
  hint(t('create_form.example_create'));
351
354
  hint(t('create_form.example_update'));
355
+ hint('Example: openyida create-form batch "APP_XXX" .cache/openyida/forms/forms.json --json');
352
356
  throwCreateFormError(t('create_form.usage_create_short'), 'CREATE_FORM_INVALID_ARGUMENTS');
353
357
  };
354
358
  }
@@ -6,12 +6,50 @@ const crypto = require('crypto');
6
6
  const { execFile } = require('child_process');
7
7
  const { CliError } = require('../../core/cli-error');
8
8
  const { t } = require('../../core/i18n');
9
+ const { usage, hint } = require('../../core/chalk');
10
+ const { assertNoEmojiInText, assertNoEmojiInValue } = require('../../core/no-emoji-guard');
11
+ const { normalizeYidaLocale } = require('../../core/yida-i18n');
12
+ const { FORM_NAV_ICONS, resolveFormNavIcon } = require('./nav-icon');
13
+
14
+ const BATCH_USAGE = 'openyida create-form batch <appType> <plan.json> [--concurrency 1..4] [--check] [--json]';
15
+ const BATCH_PLAN_EXAMPLE = '{"forms":[{"key":"customer","title":"客户","fieldsFile":"customer-fields.json"},{"key":"order","title":"订单","fieldsFile":"order-fields.json","dependsOn":["customer"]}]}';
9
16
 
10
17
  function invalid(reason) {
11
18
  throw new CliError(t('create_form.batch_invalid'), { code: 'FORM_BATCH_INVALID', details: { reason } });
12
19
  }
13
20
 
21
+ function validateStaticDefinition(form, fields) {
22
+ try {
23
+ assertNoEmojiInText(form.title, { artifact: `forms.${form.key}.title` });
24
+ assertNoEmojiInValue(fields, { artifact: `forms.${form.key}.fields` });
25
+ } catch (error) {
26
+ invalid({
27
+ formKey: form.key,
28
+ errorCode: error?.code || 'OPENYIDA_ARTIFACT_EMOJI_FORBIDDEN',
29
+ errorMsg: error?.message || String(error),
30
+ });
31
+ }
32
+ if (form.icon !== undefined && !resolveFormNavIcon(form.icon, form.title, fields).icon) {
33
+ invalid({
34
+ formKey: form.key,
35
+ errorCode: 'CREATE_FORM_NAV_ICON_INVALID',
36
+ errorMsg: `Unsupported form navigation icon: ${form.icon}`,
37
+ icon: form.icon,
38
+ availableIconCount: FORM_NAV_ICONS.length,
39
+ });
40
+ }
41
+ if (form.locale !== undefined && !normalizeYidaLocale(form.locale)) {
42
+ invalid({
43
+ formKey: form.key,
44
+ errorCode: 'CREATE_FORM_INVALID_ARGUMENTS',
45
+ errorMsg: `Unsupported locale: ${form.locale}`,
46
+ locale: form.locale,
47
+ });
48
+ }
49
+ }
50
+
14
51
  function parseArgs(args) {
52
+ if (args.includes('--help') || args.includes('-h')) { return { help: true }; }
15
53
  const options = { concurrency: 3, check: false };
16
54
  const positional = [];
17
55
  for (let i = 0; i < args.length; i++) {
@@ -25,11 +63,19 @@ function parseArgs(args) {
25
63
  }
26
64
  }
27
65
  if (positional.length !== 2 || !Number.isInteger(options.concurrency) || options.concurrency < 1 || options.concurrency > 4) {
28
- invalid('create-form batch <appType> <plan.json> [--concurrency 1..4] [--check] [--json]');
66
+ invalid(BATCH_USAGE.replace(/^openyida /, ''));
29
67
  }
30
68
  return { ...options, appType: positional[0], file: path.resolve(positional[1]) };
31
69
  }
32
70
 
71
+ function showHelp() {
72
+ usage(BATCH_USAGE);
73
+ hint('plan.json: ' + BATCH_PLAN_EXAMPLE);
74
+ hint('fieldsFile is resolved relative to plan.json; use dependsOn and {"$form":"key","field":"label"} for dependencies.');
75
+ hint('Run one authoritative batch. If it continues in background, do not stop it, delete its state, or fall back to create/update/resume.');
76
+ return { success: true, help: true, usage: BATCH_USAGE };
77
+ }
78
+
33
79
  // References occupy a complete JSON value; ordinary text is preserved.
34
80
  function mapReferences(value, resolve) {
35
81
  if (Array.isArray(value)) { return value.map(item => mapReferences(item, resolve)); }
@@ -42,7 +88,38 @@ function mapReferences(value, resolve) {
42
88
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, mapReferences(item, resolve)]));
43
89
  }
44
90
 
45
- function loadPlan(file) {
91
+ function normalizeAssociationReferences(value, appType) {
92
+ if (Array.isArray(value)) {
93
+ return value.map(item => normalizeAssociationReferences(item, appType));
94
+ }
95
+ if (!value || typeof value !== 'object') { return value; }
96
+ const normalized = Object.fromEntries(Object.entries(value).map(([key, item]) => [
97
+ key,
98
+ normalizeAssociationReferences(item, appType),
99
+ ]));
100
+ const association = value.associationForm;
101
+ if (
102
+ value.type === 'AssociationFormField' &&
103
+ association &&
104
+ typeof association === 'object' &&
105
+ !Array.isArray(association) &&
106
+ typeof association.$form === 'string'
107
+ ) {
108
+ const reference = { $form: association.$form };
109
+ normalized.associationForm = {
110
+ ...(appType ? { appType } : {}),
111
+ formUuid: reference,
112
+ ...(typeof association.field === 'string' && association.field.trim() ? {
113
+ mainFieldId: { ...reference, field: association.field },
114
+ mainFieldLabel: association.field,
115
+ mainComponentName: 'TextField',
116
+ } : {}),
117
+ };
118
+ }
119
+ return normalized;
120
+ }
121
+
122
+ function loadPlan(file, options = {}) {
46
123
  const input = JSON.parse(fs.readFileSync(file, 'utf8'));
47
124
  if (!Array.isArray(input.forms) || !input.forms.length) { invalid('forms'); }
48
125
  const forms = input.forms.map(form => {
@@ -52,7 +129,9 @@ function loadPlan(file) {
52
129
  if (Object.keys(form).some(key => !['key', 'title', 'fields', 'fieldsFile', 'dependsOn', 'formUuid', 'icon', 'locale'].includes(key))) { invalid(form.key); }
53
130
  if (form.formUuid !== undefined && (typeof form.formUuid !== 'string' || !form.formUuid.startsWith('FORM'))) { invalid(form.key); }
54
131
  for (const key of ['icon', 'locale']) { if (form[key] !== undefined && typeof form[key] !== 'string') { invalid(key); } }
55
- const fields = form.fieldsFile === undefined ? form.fields : JSON.parse(fs.readFileSync(path.resolve(path.dirname(file), form.fieldsFile), 'utf8'));
132
+ const rawFields = form.fieldsFile === undefined ? form.fields : JSON.parse(fs.readFileSync(path.resolve(path.dirname(file), form.fieldsFile), 'utf8'));
133
+ const fields = normalizeAssociationReferences(rawFields, options.appType);
134
+ validateStaticDefinition(form, fields);
56
135
  const dependencies = new Set(form.dependsOn || []);
57
136
  mapReferences(fields, key => { dependencies.add(key); return 'FORM-PRECHECK'; });
58
137
  return { ...form, fields, dependsOn: [...dependencies] };
@@ -87,14 +166,75 @@ function parseOutput(stdout) {
87
166
  return output;
88
167
  }
89
168
 
90
- function execute(args) {
169
+ function mergeCommandOutput(stdout, stderr, failed) {
170
+ const stdoutOutput = parseOutput(stdout);
171
+ const stderrOutput = parseOutput(stderr);
172
+ if (!failed) { return stdoutOutput || stderrOutput; }
173
+ if (!stdoutOutput) { return stderrOutput; }
174
+ if (!stderrOutput) { return stdoutOutput; }
175
+ return { ...stdoutOutput, ...stderrOutput };
176
+ }
177
+
178
+ function boundedDiagnostic(value) {
179
+ const text = String(value || '').trim();
180
+ return text.length > 4000 ? text.slice(-4000) : text;
181
+ }
182
+
183
+ function expectedReadbackFields(fields) {
184
+ const expected = [];
185
+ for (const field of Array.isArray(fields) ? fields : []) {
186
+ if (Array.isArray(field)) {
187
+ expected.push(...expectedReadbackFields(field));
188
+ continue;
189
+ }
190
+ if (!field || typeof field !== 'object') { continue; }
191
+ if (field.type === 'ColumnContainer') {
192
+ expected.push(...expectedReadbackFields(field.children));
193
+ continue;
194
+ }
195
+ if (['Divider', 'RichText', 'PageSection', 'GroupContainer'].includes(field.type)) { continue; }
196
+ if (typeof field.label === 'string' && field.label.trim()) {
197
+ expected.push({ label: field.label.trim(), componentName: field.type });
198
+ }
199
+ }
200
+ return expected;
201
+ }
202
+
203
+ function readbackMatchesExpectedFields(schema, fields) {
204
+ if (!schema || typeof schema.formUuid !== 'string' || !Array.isArray(schema.fields)) { return false; }
205
+ const actual = schema.fields.filter(field => field && typeof field === 'object');
206
+ return expectedReadbackFields(fields).every(expected => actual.some(field =>
207
+ field.label === expected.label && (!expected.componentName || field.componentName === expected.componentName)
208
+ ));
209
+ }
210
+
211
+ function readbackMismatch(formUuid, form, schema) {
212
+ const expected = expectedReadbackFields(form.fields);
213
+ const actual = Array.isArray(schema?.fields) ? schema.fields.map(field => ({
214
+ label: field?.label,
215
+ componentName: field?.componentName,
216
+ })) : [];
217
+ return Object.assign(new Error(`Form batch readback mismatch: ${form.key}`), {
218
+ output: {
219
+ success: false,
220
+ errorCode: 'FORM_BATCH_READBACK_MISMATCH',
221
+ errorMsg: `Created form ${form.key} did not contain all expected fields`,
222
+ formUuid,
223
+ details: { formKey: form.key, formUuid, expected, actual },
224
+ },
225
+ });
226
+ }
227
+
228
+ function execute(args, { execFile: execFileImpl = execFile } = {}) {
91
229
  return new Promise((resolve, reject) => {
92
- execFile(process.execPath, [path.resolve(__dirname, '../../../bin/yida.js'), ...args, '--quiet'], {
230
+ execFileImpl(process.execPath, [path.resolve(__dirname, '../../../bin/yida.js'), ...args, '--quiet'], {
93
231
  env: { ...process.env, YIDA_QUIET: '1' }, timeout: 180000, maxBuffer: 8 * 1024 * 1024,
94
- }, (error, stdout) => {
95
- const output = parseOutput(stdout);
232
+ }, (error, stdout, stderr) => {
233
+ const output = mergeCommandOutput(stdout, stderr, Boolean(error));
96
234
  if (error || !output || typeof output !== 'object' || output.success === false) {
97
- reject(Object.assign(new Error(output?.errorMsg || output?.error || error?.message || 'Missing command result'), { output }));
235
+ reject(Object.assign(new Error(
236
+ output?.errorMsg || output?.error || output?.message || boundedDiagnostic(stderr) || error?.message || 'Missing command result'
237
+ ), { output }));
98
238
  } else { resolve(output); }
99
239
  });
100
240
  });
@@ -106,6 +246,9 @@ async function schedule(forms, concurrency, results, worker, save) {
106
246
  return !result || result.status === 'blocked' || (result.status === 'failed' && result.formUuid);
107
247
  }).map(form => form.key));
108
248
  const active = new Map();
249
+ const recoveryAttempted = new Set(forms.filter(form =>
250
+ form.formUuid || (results[form.key]?.status === 'failed' && results[form.key]?.formUuid)
251
+ ).map(form => form.key));
109
252
  try {
110
253
  while (pending.size || active.size) {
111
254
  const pendingBefore = pending.size;
@@ -117,6 +260,8 @@ async function schedule(forms, concurrency, results, worker, save) {
117
260
  }
118
261
  if (active.size >= concurrency || deps.some(status => status !== 'success')) { continue; }
119
262
  pending.delete(form.key);
263
+ const hadKnownFormUuid = Boolean(form.formUuid || results[form.key]?.formUuid);
264
+ if (hadKnownFormUuid) { recoveryAttempted.add(form.key); }
120
265
  results[form.key] = { ...results[form.key], status: 'running' };
121
266
  save(); // Record intent before the request; an interrupted create is never retried automatically.
122
267
  const task = Promise.resolve().then(() => worker(form)).then(output => {
@@ -124,6 +269,13 @@ async function schedule(forms, concurrency, results, worker, save) {
124
269
  }, error => {
125
270
  results[form.key] = { ...results[form.key], status: 'failed', error: error.message,
126
271
  ...((error.output?.formUuid || error.output?.details?.formUuid) ? { formUuid: error.output.formUuid || error.output.details.formUuid } : {}) };
272
+ // A create may persist the form before a later schema/readback stage fails.
273
+ // Retry that known UUID once inside the same authoritative batch so its
274
+ // dependents remain pending and can run after conservative resume.
275
+ if (!hadKnownFormUuid && results[form.key].formUuid && !recoveryAttempted.has(form.key)) {
276
+ recoveryAttempted.add(form.key);
277
+ pending.add(form.key);
278
+ }
127
279
  }).then(() => { save(); }).finally(() => active.delete(form.key));
128
280
  active.set(form.key, task);
129
281
  }
@@ -140,7 +292,8 @@ async function schedule(forms, concurrency, results, worker, save) {
140
292
 
141
293
  async function run(args, dependencies = {}) {
142
294
  const options = parseArgs(args);
143
- const { forms, groups } = loadPlan(options.file);
295
+ if (options.help) { return showHelp(); }
296
+ const { forms, groups } = loadPlan(options.file, { appType: options.appType });
144
297
  const call = dependencies.execute || execute;
145
298
  // Preflight all definitions before the first remote mutation.
146
299
  await schedule(forms.map(form => ({ ...form, dependsOn: [] })), options.concurrency, {}, async form => {
@@ -202,14 +355,28 @@ async function run(args, dependencies = {}) {
202
355
  }
203
356
  state.results[form.key].formUuid = formUuid;
204
357
  save(state);
358
+ let resumed = false;
205
359
  if (shouldResume) {
206
360
  await call(['create-form', 'resume', options.appType, formUuid, JSON.stringify(resolvedFields), '--json']);
361
+ resumed = true;
362
+ }
363
+ let schema = await call(['get-schema', options.appType, formUuid, '--field-map-json']);
364
+ if (!readbackMatchesExpectedFields(schema, resolvedFields) && !resumed && !form.formUuid) {
365
+ await call(['create-form', 'resume', options.appType, formUuid, JSON.stringify(resolvedFields), '--json']);
366
+ resumed = true;
367
+ schema = await call(['get-schema', options.appType, formUuid, '--field-map-json']);
368
+ }
369
+ if (schema.formUuid !== formUuid || !readbackMatchesExpectedFields(schema, resolvedFields)) {
370
+ throw readbackMismatch(formUuid, { ...form, fields: resolvedFields }, schema);
207
371
  }
208
- const schema = await call(['get-schema', options.appType, formUuid, '--field-map-json']);
209
- if (schema.formUuid !== formUuid || !Array.isArray(schema.fields)) { invalid(`schema: ${form.key}`); }
210
372
  return { formUuid, fields: schema.fields };
211
373
  }, () => save(state));
212
- const output = { success: Object.values(state.results).every(item => item.status === 'success'), groups, stateFile, results: state.results };
374
+ const success = Object.values(state.results).every(item => item.status === 'success');
375
+ const output = { success, groups, stateFile, results: state.results };
376
+ if (!success) {
377
+ output.errorCode = 'FORM_BATCH_PARTIAL_FAILURE';
378
+ output.nextAction = 'Inspect the saved state and child error, then fix the batch input or recover known formUuid values. Do not fall back to create-form create.';
379
+ }
213
380
  console.log(JSON.stringify(output));
214
381
  if (!output.success) { process.exitCode = 1; }
215
382
  return output;
@@ -218,4 +385,17 @@ async function run(args, dependencies = {}) {
218
385
  }
219
386
  }
220
387
 
221
- module.exports = { run, parseArgs, loadPlan, mapReferences, schedule, parseOutput };
388
+ module.exports = {
389
+ run,
390
+ parseArgs,
391
+ loadPlan,
392
+ mapReferences,
393
+ normalizeAssociationReferences,
394
+ schedule,
395
+ parseOutput,
396
+ mergeCommandOutput,
397
+ execute,
398
+ expectedReadbackFields,
399
+ readbackMatchesExpectedFields,
400
+ validateStaticDefinition,
401
+ };
@@ -123,6 +123,29 @@ function createCreateFormError(message, code, details) {
123
123
  });
124
124
  }
125
125
 
126
+ function createFormPreflightError(errorObject, appType, formTitle) {
127
+ const error = errorObject && errorObject.isCliError
128
+ ? errorObject
129
+ : createCreateFormError(
130
+ errorObject?.message || String(errorObject || 'Create form preflight failed'),
131
+ 'CREATE_FORM_PREFLIGHT_FAILED'
132
+ );
133
+ const existing = error.details && typeof error.details === 'object' && !Array.isArray(error.details)
134
+ ? error.details
135
+ : {};
136
+ error.details = {
137
+ ...existing,
138
+ stage: 'preflight',
139
+ retryable: true,
140
+ retrySafe: true,
141
+ sideEffectState: 'not_started',
142
+ mutationAccepted: false,
143
+ mutationPerformed: false,
144
+ target: { resource: 'form', appType, formTitle },
145
+ };
146
+ return error;
147
+ }
148
+
126
149
  const parseArgs = createParseArgs({
127
150
  parseOpenOption,
128
151
  normalizeYidaLocale,
@@ -4902,20 +4925,28 @@ async function mainCreate(parsedArgs, authRef) {
4902
4925
  label('Fields:', fieldsJsonOrFile);
4903
4926
 
4904
4927
  step(2, t('create_form.step_read_fields', 2));
4905
- assertNoEmojiInDefinitionFileName(fieldsJsonOrFile);
4906
- const { fields, columns, validations } = readFieldsDefinition(fieldsJsonOrFile);
4907
- assertNoEmojiInFormDefinition(formTitle, fields, validations);
4908
- validateFormFieldDefinitions(fields);
4909
- const iconResolution = resolveFormNavIcon(parsedArgs.icon, formTitle, fields);
4910
- if (!iconResolution.icon) {
4911
- throwCreateFormError(
4912
- `Unsupported form navigation icon: ${parsedArgs.icon}. Run openyida create-form icons --json for the supported catalog.`,
4913
- 'CREATE_FORM_NAV_ICON_INVALID',
4914
- {
4915
- icon: parsedArgs.icon,
4916
- availableIconCount: FORM_NAV_ICONS.length,
4917
- }
4918
- );
4928
+ let fields;
4929
+ let columns;
4930
+ let validations;
4931
+ let iconResolution;
4932
+ try {
4933
+ assertNoEmojiInDefinitionFileName(fieldsJsonOrFile);
4934
+ ({ fields, columns, validations } = readFieldsDefinition(fieldsJsonOrFile));
4935
+ assertNoEmojiInFormDefinition(formTitle, fields, validations);
4936
+ validateFormFieldDefinitions(fields);
4937
+ iconResolution = resolveFormNavIcon(parsedArgs.icon, formTitle, fields);
4938
+ if (!iconResolution.icon) {
4939
+ throwCreateFormError(
4940
+ `Unsupported form navigation icon: ${parsedArgs.icon}. Run openyida create-form icons --json for the supported catalog.`,
4941
+ 'CREATE_FORM_NAV_ICON_INVALID',
4942
+ {
4943
+ icon: parsedArgs.icon,
4944
+ availableIconCount: FORM_NAV_ICONS.length,
4945
+ }
4946
+ );
4947
+ }
4948
+ } catch (preflightError) {
4949
+ throw createFormPreflightError(preflightError, appType, formTitle);
4919
4950
  }
4920
4951
  const formIcon = iconResolution.icon;
4921
4952
  const fieldCount = countDataFieldDefinitions(fields);
@@ -221,6 +221,16 @@ function normalizeLayoutDirection(layoutDirection) {
221
221
  return normalized;
222
222
  }
223
223
 
224
+ /** 与 yc-utils 的应用配置归一化一致;不应用详情页 Shell 的 top_fold 覆盖。 */
225
+ function normalizeStoredLayoutDirection(layoutDirection, navType) {
226
+ if (SUPPORTED_LAYOUT_DIRECTIONS.includes(layoutDirection)) {return layoutDirection;}
227
+ if (layoutDirection === 'hoz') {return navType === 'top_side' ? 'l_shape' : 'top';}
228
+ if (layoutDirection === 'ver') {return 'side';}
229
+ if (navType === 'top_fold') {return 'top';}
230
+ if (navType === 'top_side') {return 'l_shape';}
231
+ return 'side';
232
+ }
233
+
224
234
  function pickAppField(currentApp, fieldName, configName) {
225
235
  if (currentApp[fieldName] !== undefined && currentApp[fieldName] !== null) {
226
236
  return currentApp[fieldName];
@@ -296,6 +306,8 @@ function buildUpdateAppPostData(params, currentApp = {}, authRef) {
296
306
  const currentCustomThemeStyle = pickAppField(currentApp, 'customThemeStyle', 'CUSTOM_THEME_STYLE');
297
307
  const addWaterMark = pickAppField(currentApp, 'addWaterMark', 'ADDWATERMARK');
298
308
  const sentryMode = pickAppField(currentApp, 'sentryMode', 'SENTRY_MODE');
309
+ const currentNavType = currentApp.navType || (currentApp.config && currentApp.config.NAVTYPE);
310
+ const currentLayoutDirection = currentApp.layoutDirection || (currentApp.config && currentApp.config.LAY_OUT_DIRECTION);
299
311
  if (params.colour === 'custom' && !params.themeColor && !currentThemeColor) {
300
312
  throw new Error(t('update_app.custom_theme_color_required'));
301
313
  }
@@ -319,9 +331,10 @@ function buildUpdateAppPostData(params, currentApp = {}, authRef) {
319
331
  mode: currentApp.mode || (currentApp.config && currentApp.config.APPMODE) || 'normal',
320
332
  type: currentApp.type || (currentApp.config && currentApp.config.APPTYPE) || 'single',
321
333
  navTheme: params.navTheme || currentApp.navTheme || (currentApp.config && currentApp.config.NAV_THEME) || 'light',
322
- navType: currentApp.navType || (currentApp.config && currentApp.config.NAVTYPE) || 'top_side',
323
334
  navLayout: currentApp.navLayout || (currentApp.config && currentApp.config.NAVLAYOUT) || 'auto',
324
- layoutDirection: params.layoutDirection || currentApp.layoutDirection || (currentApp.config && currentApp.config.LAY_OUT_DIRECTION) || 'side',
335
+ layoutDirection: params.layoutDirection
336
+ ? normalizeLayoutDirection(params.layoutDirection)
337
+ : normalizeStoredLayoutDirection(currentLayoutDirection, currentNavType),
325
338
  homepageLogo: currentApp.homepageLogo || (currentApp.config && currentApp.config.HOMEPAGELOGO) || '',
326
339
  logoSource: params.logoSource || currentApp.logoSource || (currentApp.config && currentApp.config.LOGO_SOURCE) || 'appIcon',
327
340
  logoLink: currentApp.logoLink || (currentApp.config && currentApp.config.LOGOLINK) || '',
@@ -346,6 +359,8 @@ function buildUpdateAppPostData(params, currentApp = {}, authRef) {
346
359
  pageFooter: currentApp.pageFooter || '',
347
360
  };
348
361
 
362
+ // ThemeNavSetting 保存现有 navType;新布局由 layoutDirection 控制,不反写或补造旧字段。
363
+ if (currentNavType) {postDataObj.navType = currentNavType;}
349
364
  if (addWaterMark !== undefined && addWaterMark !== null) {
350
365
  postDataObj.addWaterMark = addWaterMark;
351
366
  }
@@ -90,8 +90,17 @@ function shouldUseStructuredErrorOutput(error, args = []) {
90
90
  && details.retrySafe === false
91
91
  && details.sideEffectState === 'unknown'
92
92
  );
93
+ const deterministicPreflightFailure = !!(
94
+ details
95
+ && typeof details === 'object'
96
+ && !Array.isArray(details)
97
+ && details.stage === 'preflight'
98
+ && details.retrySafe === true
99
+ && details.sideEffectState === 'not_started'
100
+ && details.mutationPerformed === false
101
+ );
93
102
  const navigationOrderFailure = /^NAV_ORDER_/.test(error.code || '');
94
- return ownedPartial || mutationOutcomeUnknown || navigationOrderFailure;
103
+ return ownedPartial || mutationOutcomeUnknown || deterministicPreflightFailure || navigationOrderFailure;
95
104
  }
96
105
 
97
106
  module.exports = {
@@ -1555,8 +1555,8 @@ function summarizeLocalizedCommands(commands) {
1555
1555
  plan_command_ids: ['design-plan.init', 'design-plan.preview', 'design-plan.materialize', 'design-plan.patch'],
1556
1556
  theme_command_ids: ['sample', 'create-app', 'update-app'],
1557
1557
  navigation_command_ids: { platform: ['update-app', 'nav-group'], custom: ['update-app', 'update-form-config', 'get-form-config'] },
1558
- navigation_policy: 'Before PRD planning, reuse explicit navigation choices. During first-time intake, ask only when unknown: platform-l-shape, platform-top, platform-side, custom, with neutral descriptions and no preselection. For custom navigation also settle side/top/mixed/dock. Store type/source/reason/variant in the brief. Custom navigation requires app and per-page navigation hiding with readback; layout and navigation tone are separate choices.',
1559
- design_mode_policy: 'Analyze requirements first. First-time builds include new apps and existing apps without business pages. Reuse detailed supplied plans; if the mode is unspecified, ask whether to prepare a PRD for confirmation (Plan, more detailed and slower) or build from the supplied requirements (Fast). Without detailed requirements, offer Fast and Plan neutrally. Confirm unresolved navigation, custom navigation layout, visual style and page scope before planning or resource creation. Existing business apps only clarify the current change. Plan uses design-plan init parallelTasks for concurrent business and base visuals in separate files, then page visual bindings; publish settled modules with design-plan preview, then materialize --from-preview validates the complete draft and writes PRD, design, HTML and theme; the business-file/visual-file merge remains available; confirm the displayed revision before building.',
1558
+ navigation_policy: 'Before PRD planning, reuse explicit navigation choices. During first-time intake, ask only when ownership is unknown and offer exactly two neutral options with no preselection: "宜搭原生导航" (使用宜搭自带的导航菜单。) and "自定义导航" (在自定义页面里定制菜单的样式和操作方式,替代宜搭自带的导航菜单。). Native means platform navigation. The agent chooses layout from module count, hierarchy, switching frequency, content width and target device; do not ask layout or presentation-style questions. Prefer top for few shallow modules and wide content, side for many frequently accessed modules, L-shaped/mixed for business domains with child modules, and custom dock for lightweight mobile entries. Preserve user-specified layouts. Store resolved type/source/reason/variant in the brief, distinguishing user ownership from agent layout reasoning. Platform navigation uses update-app --layout top|side|l_shape --show-app-nav, which writes layoutDirection=top|side|l_shape and hideAppNav=n. Preserve stored navType unchanged; do not derive or invent it and do not add a --nav-type option. Without --layout, normalize stored legacy layoutDirection and navType using the yc-utils application rules (hoz plus top_side means l_shape; missing layout uses top_fold/top_side for top/l_shape). Shell page-level top_fold/none overrides do not belong in app settings. Custom side/top/mixed/dock is a page layout and requires app and per-page navigation hiding with readback. Verify persisted layoutDirection and hideAppNav; updatedFields and themeVerification do not prove navigation persistence. Navigation tone is separate from layout.',
1559
+ design_mode_policy: 'Analyze requirements first. First-time builds include new apps and existing apps without business pages. Reuse detailed supplied plans; if the mode is unspecified, ask whether to prepare a PRD for confirmation (Plan, more detailed and slower) or build from the supplied requirements (Fast). Without detailed requirements, offer Fast and Plan neutrally. Confirm unresolved navigation ownership, visual style and page scope before planning or resource creation; the agent determines navigation layout from the business context without a layout question. Existing business apps only clarify the current change. A named resource-only request with explicitScope.allowInferredResources=false skips unrelated navigation and visual questions. Standard Plan uses design-plan init parallelTasks, then executes the returned materialize.command exactly once with business-file and visual-file; preview and --from-preview are only for explicitly incremental large plans. Confirm the displayed revision before building, and do not materialize or patch again after confirmation.',
1560
1560
  product_design_policy: 'yida-requirement-analysis owns shared facts and first-time intake. yida-prd owns business planning; yida-design owns visual design. Reuse the same confirmed brief and supplied details. Prepare business and base visuals concurrently, then bind visuals to settled page tasks. yida-app merges and checks the artifacts before creating resources. Plan uses the compact authoring contract and selected theme context; the CLI reads full templates and renders all artifacts.',
1561
1561
  ui_guidance_policy: 'Page implementation consumes yida-prd prd.md for positioning, information architecture, page prototype, native form entry policy, material strategy, business-specific checks, resource creation order, page implementation delivery order, navigation order, and acceptance criteria; it consumes yida-design design.md for app custom theme CSS delivery, themeColor/navTheme, visual states, visualScaffold, surface material, rounded rules, density rules, components, and state styling. prd.md and design.md are the only design sources of truth. Page implementation may extract page-spec.json as a derived implementation handoff from prd.md + design.md; conflicts are resolved by sending business conflicts to yida-prd and visual conflicts to yida-design before regenerating the spec. Core normal forms default to 1-3 business sample records before page implementation, followed by query readback. An explicit opt-out, configuration dictionary, sensitive data, or lack of safely constructible values requires a recorded skip reason. Screenshots, public sharing, data-source deep binding, and fine navigation grouping are optional after explicit user request or PRD acceptance criteria.',
1562
1562
  default_nav_order_policy: 'For custom navigation, implement PRD navigation order in the custom shell and verify app/page navigation hiding; skip platform nav-group ordering. For platform navigation: after the primary page is successfully published, perform exactly one navigation order operation. If the PRD names a navigation order, publish without --auto-nav-order and then call openyida nav-group order <appType> <items...>. If the PRD only gives broad groups or is missing navigation order, use openyida publish ... --auto-nav-order and do not call nav-group order or auto-order afterward. Explicit and automatic ordering are mutually exclusive; never generate per-item move loops. The fallback priority is portal/home/workbench entry > business handling > data management > business analytics > system configuration.',
@@ -8,14 +8,49 @@ const { planBase } = require('./parallel');
8
8
 
9
9
  const ROOT = path.resolve(__dirname, '../../yida-skills/skills/yida-design/sub_skill/yida-design-plan');
10
10
 
11
+ function list(value) {
12
+ return Array.isArray(value) ? value : [];
13
+ }
14
+
15
+ function customScene(scene) {
16
+ return !['form', 'process-form', 'report'].includes(scene?.kind);
17
+ }
18
+
19
+ function resourceOnlyScope(brief) {
20
+ if (brief.explicitScope?.allowInferredResources === false) {return true;}
21
+ const scopedForms = list(brief.explicitScope?.forms);
22
+ const confirmedCustomPages = list(brief.pageScenes).filter(customScene);
23
+ return scopedForms.length > 0 && confirmedCustomPages.length === 0;
24
+ }
25
+
26
+ function scopeEntryMatchesScene(entry, scene) {
27
+ const candidate = typeof entry === 'string' ? entry : entry?.key || entry?.name;
28
+ return candidate && [scene?.key, scene?.name].includes(candidate);
29
+ }
30
+
11
31
  function initialize(inputPath, options = {}) {
12
32
  const input = path.resolve(inputPath);
13
33
  const brief = readJson(input, 'requirement-brief.json');
14
34
  const project = brief.projectName;
35
+ if ((project === undefined || project === null || project === '')
36
+ && typeof brief.meta?.projectName === 'string'
37
+ && brief.meta.projectName.trim()) {
38
+ throw new CliError('projectName 必须位于 requirement-brief.json 根级;检测到 meta.projectName,请将该字段移动到根级后重试,不要更换项目名', {
39
+ code: 'DESIGN_PLAN_PROJECT_NAME_MISPLACED',
40
+ details: {
41
+ expectedPath: 'projectName',
42
+ actualPath: 'meta.projectName',
43
+ nextAction: 'move_project_name_to_root',
44
+ retryable: true,
45
+ retrySafe: true,
46
+ },
47
+ });
48
+ }
15
49
  if (typeof project !== 'string' || !project.trim() || /[/\\]/.test(project) || ['.', '..'].includes(project)) {
16
50
  throw new CliError('projectName 必须是单层项目目录名', { code: 'DESIGN_PLAN_INVALID_PROJECT' });
17
51
  }
18
- if (brief.intake?.confirmed !== true || brief.openQuestions?.length) {
52
+ const resourceOnly = resourceOnlyScope(brief);
53
+ if ((!resourceOnly && brief.intake?.confirmed !== true) || brief.openQuestions?.length) {
19
54
  throw new CliError('请先完成需求确认并记录 intake.confirmed=true', { code: 'DESIGN_PLAN_INTAKE_REQUIRED' });
20
55
  }
21
56
  if (!['platform-l-shape', 'platform-top', 'platform-side', 'custom'].includes(brief.navigation?.type)
@@ -38,7 +73,10 @@ function initialize(inputPath, options = {}) {
38
73
  if ([output, guide, business, visual].some(file => fs.existsSync(file))) {
39
74
  throw new CliError('计划或编写上下文已存在,请继续补充现有文件', { code: 'DESIGN_PLAN_ALREADY_EXISTS' });
40
75
  }
41
- const scenes = (brief.pageScenes || []).filter(scene => !['form', 'process-form', 'report'].includes(scene?.kind));
76
+ const scopedPages = list(brief.explicitScope?.pages);
77
+ const scenes = list(brief.pageScenes)
78
+ .filter(customScene)
79
+ .filter(scene => !resourceOnly || scopedPages.some(entry => scopeEntryMatchesScene(entry, scene)));
42
80
  const pages = scenes.map((scene, index) => {
43
81
  const source = typeof scene === 'string' ? { name: scene } : scene;
44
82
  const { key, ...facts } = source;
@@ -49,19 +87,49 @@ function initialize(inputPath, options = {}) {
49
87
  density: '', permissionSummary: '', dataBinding: '', dataSources: [], ...facts };
50
88
  });
51
89
  const choice = brief.visualSelection || {};
52
- const app = brief.resourceContext?.app || {};
90
+ const app = brief.resourceContext?.app || brief.resourceContext || {};
53
91
  const appConfig = { navigationType: brief.navigation.type };
54
92
  for (const key of ['appType', 'corpId', 'baseUrl']) {if (app[key]) {appConfig[key] = app[key];}}
93
+ const businessGoals = Array.isArray(brief.businessGoals)
94
+ ? brief.businessGoals
95
+ : (typeof brief.businessGoals === 'string' && brief.businessGoals.trim() ? [brief.businessGoals] : []);
96
+ const modelInputs = (resourceOnly && list(brief.explicitScope?.forms).length > 0
97
+ ? list(brief.explicitScope.forms)
98
+ : list(brief.businessObjects)).map(item => {
99
+ if (typeof item !== 'string') {return item;}
100
+ return list(brief.businessObjects).find(candidate => candidate?.name === item) || item;
101
+ });
102
+ const normalizedScope = {
103
+ ...brief.explicitScope,
104
+ ...(resourceOnly ? {
105
+ allowInferredResources: false,
106
+ pages: scopedPages.filter(entry => scenes.some(scene => scopeEntryMatchesScene(entry, scene))),
107
+ } : {}),
108
+ };
109
+ const rolePermissionSummary = list(brief.targetUsers)
110
+ .map(user => typeof user === 'string' ? `${user}:使用本轮范围内已授权资源` : '')
111
+ .filter(Boolean);
112
+ if (resourceOnly && rolePermissionSummary.length === 0) {
113
+ rolePermissionSummary.push('应用成员:使用本轮范围内已授权资源');
114
+ }
55
115
  const plan = {
56
116
  schemaVersion: '2.0',
57
- meta: { projectName: project, appName: brief.appName || project, revision: '1', status: 'draft', source: input,
117
+ meta: { projectName: project, appName: brief.appName || brief.projectDisplayName || brief.meta?.displayName || project, revision: '1', status: 'draft', source: input,
58
118
  businessDomain: brief.industry || '', experienceTopology: '',
59
119
  planState: { planConfirmed: false, presentedRevision: null, confirmedRevision: null } },
60
- overview: { summary: (brief.businessGoals || []).join(';'), businessGraph: { relations: [] }, rolePermissionSummary: [] },
61
- dataModels: (brief.businessObjects || []).map(item => {
120
+ overview: { summary: businessGoals.join(';'), businessGraph: { relations: [] }, rolePermissionSummary },
121
+ dataModels: modelInputs.map(item => {
62
122
  const model = typeof item === 'string' ? { name: item } : item;
63
123
  return { ...model, name: model.name, formType: model.formType || '宜搭表单', description: model.description || '', fields: Array.isArray(model.fields) ? model.fields : [] };
64
- }), businessFlows: [], pages: { customPageDetails: pages }, execution: { appConfig, explicitScope: { ...brief.explicitScope, navigation: brief.navigation } },
124
+ }), businessFlows: [], pages: { customPageDetails: pages }, execution: {
125
+ appConfig,
126
+ explicitScope: { ...normalizedScope, navigation: brief.navigation },
127
+ ...(resourceOnly ? {
128
+ sampleDataPlan: modelInputs
129
+ .filter(item => !String(item?.formType || '').includes('流程'))
130
+ .map(item => ({ form: typeof item === 'string' ? item : item.name, skipReason: '本轮显式窄范围不包含示例数据' })),
131
+ } : {}),
132
+ },
65
133
  visualStyle: {
66
134
  ...(choice.tokens ? { tokens: choice.tokens } : {}),
67
135
  ...(choice.evidence ? { evidence: choice.evidence } : {}),
@@ -82,14 +150,23 @@ function initialize(inputPath, options = {}) {
82
150
  const patterns = readJson(path.join(ROOT, 'templates/page-patterns/index.json')).patterns;
83
151
  const context = [
84
152
  '# 计划编写上下文', '', `需求事实:${input}`, `计划目录:${outputDir}`, '',
85
- '先读 build-plan-compact-schema.md;标准首版一次完成 business.json,复用已预填的 visual.json 后合并物化。只有视觉选择不完整或存在品牌稿、参考图、逐页特殊视觉要求时才补充视觉。保留各文件 base、页面 ID 与已有需求细节;普通首版不逐模块调用 preview。', '',
153
+ `先读 build-plan-compact-schema.md,再读 init 已创建的 business.json;保留 base 并一次补完 facts 后写回。business facts 只能包含 overview、dataModels、businessFlows、pages 和可选 execution,不得写 visualStyle。写完后只执行一次:openyida design-plan materialize ${output} --business-file ${business} --visual-file ${visual} --json。标准首版禁止先调用 --from-preview、preview、--check 或无参数 materialize,也不要用 Glob 或额外 Read 检查产物;成功结果已返回 HTML 路径和 revision。只有视觉选择不完整或存在品牌稿、参考图、逐页特殊视觉要求时才补充视觉。保留页面 ID 与已有需求细节。`, '',
86
154
  '## 页面模式', ...patterns.map(item => `- ${item.id}(${item.label}):${item.mustKeep.join(';')}`), '',
87
155
  `## 已选风格:${theme.label}`, overview.trim(), '',
88
156
  '用户确认的配色优先于模板固定灰阶:surfaceTone=brand-tinted 将浅色页面、填充和边界与主色协调,正文保留中性层级;用户明确只改强调色、保持中性或忠实参考配色时设为 theme。深色表面保留原层级。不要为了模板的无彩限制撤掉用户要求的品牌氛围。', '',
89
157
  '完整主题的 token、组件、状态和响应式规则由 CLI 注入 design.md;调整具体组件时按需读取对应模板章节。', '',
90
158
  ].join('\n');
91
159
  const base = planBase(plan);
92
- const businessPart = `${JSON.stringify({ base, ready: false, facts: {} }, null, 2)}\n`;
160
+ const businessFacts = Object.fromEntries(
161
+ ['overview', 'dataModels', 'businessFlows', 'pages', 'execution'].map(key => [key, plan[key]]),
162
+ );
163
+ const businessReady = resourceOnly
164
+ && businessGoals.length > 0
165
+ && rolePermissionSummary.length > 0
166
+ && plan.dataModels.length > 0
167
+ && plan.dataModels.every(model => model.name && list(model.fields).length > 0
168
+ && model.fields.every(field => field?.name && field?.type && typeof field.required === 'boolean'));
169
+ const businessPart = `${JSON.stringify({ base, ready: businessReady, facts: businessFacts }, null, 2)}\n`;
93
170
  // The intake already records an atomic visual selection. Seed it here so a
94
171
  // standard first Plan only needs one model planning pass for business facts.
95
172
  const selectedVisual = plan.visualStyle.forUser;
@@ -100,10 +177,11 @@ function initialize(inputPath, options = {}) {
100
177
  const visualPart = `${JSON.stringify({ base, ready: visualReady, facts: { visualStyle: plan.visualStyle } }, null, 2)}\n`;
101
178
  writeFiles([[output, `${JSON.stringify(plan, null, 2)}\n`], [guide, context], [business, businessPart], [visual, visualPart]]);
102
179
  return { success: true, input, output, context: guide, contract: path.join(ROOT, 'references/build-plan-compact-schema.md'), draft: true,
103
- preview: { command: `openyida design-plan preview ${output} --part-file <module.json> --json`, modules: ['overview', 'dataModels', 'businessFlows', 'pages', 'visualStyle'], finalize: `openyida design-plan materialize ${output} --from-preview --json` },
104
- parallelTasks: [{ id: 'business', skill: 'yida-prd', output: business, dependsOn: [] },
180
+ materialize: { mode: 'complete_files_once', maxCalls: 1,
181
+ command: `openyida design-plan materialize ${output} --business-file ${business} --visual-file ${visual} --json` },
182
+ parallelTasks: [...(!businessReady ? [{ id: 'business', skill: 'yida-prd', output: business, dependsOn: [] }] : []),
105
183
  ...(!visualReady ? [{ id: 'visual-selection', skill: 'yida-design', output: visual, dependsOn: [] }] : [])],
106
- preparedInputs: { visual, visualReady },
184
+ preparedInputs: { business, businessReady, visual, visualReady },
107
185
  optionalTasks: [{ id: 'visual-refinement', skill: 'yida-design', input: business, output: visual, dependsOn: ['business'],
108
186
  when: 'brand material, reference images, or explicit page-specific visual differences require refinement' }],
109
187
  };