openxiangda-devkit-core 2.0.0-alpha.72 → 2.0.0-alpha.75

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,7 +1,7 @@
1
1
  import { SCHEMA_VERSIONS, DATA_FIELD_TYPES, PLATFORM_EVENT_TYPES_V2, nativePlatformCapabilityCatalog, validateDataResource, } from 'openxiangda-contracts';
2
2
  import { Ajv2020 } from 'ajv/dist/2020.js';
3
3
  import { CronExpressionParser } from 'cron-parser';
4
- import { fieldNullable, isGeneratedField } from './field-codec.js';
4
+ import { fieldNullable, supportsGeneratedMutation, } from './field-codec.js';
5
5
  import { supportsFilter, supportsSearch, supportsSort, } from './field-query-plan.js';
6
6
  import { physicalPlanForField } from './field-physical-plan.js';
7
7
  import { SEMANTIC_FIELD_PATH_PATTERN, semanticFieldPathRoot, validateAuthzSemanticBindings, } from './field-policy-path.js';
@@ -19,6 +19,150 @@ const DATE_TRIGGER_RESERVED_DATA_FIELDS = new Set([
19
19
  'dueAt',
20
20
  ]);
21
21
  const APP_DATA_FIELD_TYPES = new Set(DATA_FIELD_TYPES);
22
+ function adminNavigationItem(page, options = {}) {
23
+ return Object.freeze({ page: Object.freeze(page), ...options });
24
+ }
25
+ /** Explicitly owns the complete admin menu; the compiler never appends pages. */
26
+ export function defineAdminNavigation(groups) {
27
+ return Object.freeze(groups.map(group => Object.freeze({
28
+ ...group,
29
+ items: Object.freeze(group.items.map(item => Object.freeze({ ...item }))),
30
+ })));
31
+ }
32
+ export function adminNavigationGroup(code, label, items, options = {}) {
33
+ return Object.freeze({ code, label, items: Object.freeze([...items]), ...options });
34
+ }
35
+ export function adminResourcePage(resourceCode, options) {
36
+ return adminNavigationItem({ kind: 'resource', resourceCode }, options);
37
+ }
38
+ export function adminOperationPage(routeCode, options) {
39
+ return adminNavigationItem({ kind: 'operation', routeCode }, options);
40
+ }
41
+ export function adminApplicationTodoCenterPage(options) {
42
+ return adminNavigationItem({ kind: 'application-todo-center' }, options);
43
+ }
44
+ export function adminWorkflowWorkCenterPage(options) {
45
+ return adminNavigationItem({ kind: 'workflow-work-center' }, options);
46
+ }
47
+ export function adminWorkflowLaunchPage(workflowCode, options) {
48
+ return adminNavigationItem({ kind: 'workflow-launch', workflowCode }, options);
49
+ }
50
+ /**
51
+ * Produces a deterministic one-time authoring suggestion for tooling. The
52
+ * compiler never calls this function and never mutates a saved declaration.
53
+ */
54
+ export function suggestAdminNavigation(config) {
55
+ const groups = [];
56
+ const resources = [...(config.data?.resources || [])]
57
+ .filter(resource => resource.surface?.generated?.list !== false)
58
+ .sort((left, right) => left.code < right.code ? -1 : left.code > right.code ? 1 : 0);
59
+ if (resources.length > 0) {
60
+ groups.push(adminNavigationGroup('data-management', '数据管理', resources.map(resource => adminResourcePage(resource.code, { label: resource.name })), { icon: 'database', order: 100 }));
61
+ }
62
+ const routes = [...(config.frontend.routes || [])]
63
+ .filter(route => route.surface === 'admin' && !/[:*]/.test(route.path))
64
+ .sort((left, right) => left.code < right.code ? -1 : left.code > right.code ? 1 : 0);
65
+ if (routes.length > 0) {
66
+ groups.push(adminNavigationGroup('business-operations', '业务操作', routes.map(route => adminOperationPage(route.code, { label: route.label })), { icon: 'operations', order: 200 }));
67
+ }
68
+ const workflowCodes = [
69
+ ...new Set((config.workflows?.definitions || []).map(declaration => declaration.definition.code)),
70
+ ].sort();
71
+ const workflowItems = [];
72
+ if (workflowCodes.length > 0) {
73
+ workflowItems.push(adminWorkflowWorkCenterPage({ label: '我的审批' }));
74
+ }
75
+ for (const code of workflowCodes) {
76
+ const declarations = (config.workflows?.definitions || []).filter(declaration => declaration.definition.code === code);
77
+ const activeVersion = config.workflows?.activations.find(activation => activation.workflowCode === code)?.definitionVersion;
78
+ const selected = declarations.find(declaration => declaration.version === activeVersion) ||
79
+ [...declarations].sort((left, right) => right.version - left.version)[0];
80
+ if (selected?.launch?.mode === 'standalone') {
81
+ workflowItems.push(adminWorkflowLaunchPage(code, {
82
+ label: `发起${selected.definition.title}`,
83
+ }));
84
+ }
85
+ }
86
+ if (workflowItems.length > 0) {
87
+ groups.push(adminNavigationGroup('workflow-center', '流程中心', workflowItems, {
88
+ icon: 'workflow',
89
+ order: 300,
90
+ }));
91
+ }
92
+ return defineAdminNavigation(groups);
93
+ }
94
+ function renderAdminNavigationOptions(options) {
95
+ const entries = [
96
+ ...(options.label !== undefined
97
+ ? [`label: ${JSON.stringify(options.label)}`]
98
+ : []),
99
+ ...(options.icon !== undefined
100
+ ? [`icon: ${JSON.stringify(options.icon)}`]
101
+ : []),
102
+ ...(options.order !== undefined ? [`order: ${options.order}`] : []),
103
+ ];
104
+ return entries.length > 0 ? `{ ${entries.join(', ')} }` : undefined;
105
+ }
106
+ /**
107
+ * Renders the deterministic proposal as a typed expression that an AI author
108
+ * can copy once into frontend.admin.navigation and then edit normally.
109
+ */
110
+ export function renderAdminNavigationSuggestion(config) {
111
+ const navigation = suggestAdminNavigation(config);
112
+ const imports = new Set(['defineAdminNavigation']);
113
+ const groups = navigation.map(group => {
114
+ imports.add('adminNavigationGroup');
115
+ const items = group.items.map(item => {
116
+ const options = renderAdminNavigationOptions(item);
117
+ let expression;
118
+ switch (item.page.kind) {
119
+ case 'resource':
120
+ imports.add('adminResourcePage');
121
+ expression = `adminResourcePage(${JSON.stringify(item.page.resourceCode)}${options ? `, ${options}` : ''})`;
122
+ break;
123
+ case 'operation':
124
+ imports.add('adminOperationPage');
125
+ expression = `adminOperationPage(${JSON.stringify(item.page.routeCode)}${options ? `, ${options}` : ''})`;
126
+ break;
127
+ case 'application-todo-center':
128
+ imports.add('adminApplicationTodoCenterPage');
129
+ expression = `adminApplicationTodoCenterPage(${options || ''})`;
130
+ break;
131
+ case 'workflow-work-center':
132
+ imports.add('adminWorkflowWorkCenterPage');
133
+ expression = `adminWorkflowWorkCenterPage(${options || ''})`;
134
+ break;
135
+ case 'workflow-launch':
136
+ imports.add('adminWorkflowLaunchPage');
137
+ expression = `adminWorkflowLaunchPage(${JSON.stringify(item.page.workflowCode)}${options ? `, ${options}` : ''})`;
138
+ break;
139
+ }
140
+ return ` ${expression},`;
141
+ });
142
+ const options = renderAdminNavigationOptions({
143
+ icon: group.icon,
144
+ order: group.order,
145
+ });
146
+ return [
147
+ ' adminNavigationGroup(',
148
+ ` ${JSON.stringify(group.code)},`,
149
+ ` ${JSON.stringify(group.label)},`,
150
+ ' [',
151
+ ...items,
152
+ ' ],',
153
+ ...(options ? [` ${options}`] : []),
154
+ ' ),',
155
+ ].join('\n');
156
+ });
157
+ return {
158
+ module: 'openxiangda/config',
159
+ imports: [...imports].sort(),
160
+ proposal: navigation,
161
+ expression: ['defineAdminNavigation([', ...groups, '])'].join('\n'),
162
+ groupCount: navigation.length,
163
+ itemCount: navigation.reduce((count, group) => count + group.items.length, 0),
164
+ };
165
+ }
22
166
  const authorizationProjectionFieldPathSchema = {
23
167
  type: 'string',
24
168
  pattern: '^[A-Za-z_][A-Za-z0-9_]{0,127}(?:\\.(?:value|snapshot\\.[A-Za-z_][A-Za-z0-9_]{0,127}))?$',
@@ -625,6 +769,7 @@ export function validateAppConfig(value) {
625
769
  diagnostics.push(diagnostic('APP_CONFIG_BACKEND_RESOURCE_PROFILE_UNSUPPORTED', 'backend.resourceProfile 必须是 light 或 standard', 'backend.resourceProfile'));
626
770
  }
627
771
  validateFrontendRoutes(frontend.routes, diagnostics);
772
+ validateAdminNavigation(config, diagnostics);
628
773
  validatePerspectives(config.perspectives, object(config.authz).roles, diagnostics);
629
774
  const declaredResourceCodes = new Set((Array.isArray(object(config.data).resources)
630
775
  ? object(config.data).resources
@@ -765,6 +910,24 @@ export function validateAppConfig(value) {
765
910
  if (!Array.isArray(authz.roles)) {
766
911
  diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_ROLES_REQUIRED', 'authz.roles 必须是数组', 'authz.roles'));
767
912
  }
913
+ const forbiddenNativeMutationCapabilities = new Map();
914
+ for (const rawResource of Array.isArray(data.resources)
915
+ ? data.resources
916
+ : []) {
917
+ const resource = object(rawResource);
918
+ const mutationOwner = string(object(resource.surface).mutationOwner) || 'native';
919
+ if (mutationOwner === 'native')
920
+ continue;
921
+ for (const operation of ['create', 'update', 'delete']) {
922
+ const capability = string(object(resource.capabilities)[operation]);
923
+ if (capability) {
924
+ forbiddenNativeMutationCapabilities.set(capability, {
925
+ resourceCode: string(resource.code),
926
+ operation,
927
+ });
928
+ }
929
+ }
930
+ }
768
931
  const roleCodes = new Set();
769
932
  roles.forEach((raw, index) => {
770
933
  const role = object(raw);
@@ -791,6 +954,10 @@ export function validateAppConfig(value) {
791
954
  seenCapabilities.has(capability)) {
792
955
  diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_CAPABILITY_INVALID', 'capability 必须是非空稳定标识,且同一角色内不能重复', `authz.roles[${index}].capabilities[${capabilityIndex}]`));
793
956
  }
957
+ const forbiddenMutation = forbiddenNativeMutationCapabilities.get(capability);
958
+ if (forbiddenMutation) {
959
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_NATIVE_MUTATION_GRANT_FORBIDDEN', `${forbiddenMutation.resourceCode} 的 ${forbiddenMutation.operation} 由非 Native owner 管理,应用角色不能获得对应 Native Data capability`, `authz.roles[${index}].capabilities[${capabilityIndex}]`));
960
+ }
794
961
  seenCapabilities.add(capability);
795
962
  });
796
963
  });
@@ -1580,6 +1747,10 @@ export function validateAppConfig(value) {
1580
1747
  const editableParameters = Array.isArray(workflows.editableParameters)
1581
1748
  ? workflows.editableParameters
1582
1749
  : [];
1750
+ const frontendRoutesByCode = new Map((Array.isArray(frontend.routes) ? frontend.routes : []).map(routeValue => {
1751
+ const route = object(routeValue);
1752
+ return [string(route.code), route];
1753
+ }));
1583
1754
  const definitionByKey = new Map();
1584
1755
  const bindingByKey = new Map();
1585
1756
  const providerKeys = new Set();
@@ -1630,9 +1801,62 @@ export function validateAppConfig(value) {
1630
1801
  const definition = object(declaration.definition);
1631
1802
  const version = Number(declaration.version);
1632
1803
  const path = `workflows.definitions[${index}]`;
1804
+ const launch = object(declaration.launch);
1805
+ if (Object.keys(declaration).some(key => !['version', 'definition', 'launch', 'detailRouteCode'].includes(key))) {
1806
+ diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_DEFINITION_DECLARATION_INVALID', 'Workflow definition declaration 只能声明 version、definition、launch 和 detailRouteCode', path));
1807
+ }
1808
+ if (declaration.launch !== undefined &&
1809
+ (Object.keys(launch).some(key => key !== 'mode') ||
1810
+ ![
1811
+ 'standalone',
1812
+ 'custom-page',
1813
+ 'hidden-handoff',
1814
+ 'work-center-only',
1815
+ ].includes(string(launch.mode)))) {
1816
+ diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_LAUNCH_MODE_INVALID', 'Workflow launch.mode 必须是 standalone、custom-page、hidden-handoff 或 work-center-only', `${path}.launch.mode`));
1817
+ }
1818
+ if (declaration.detailRouteCode !== undefined) {
1819
+ const detailRouteCode = object(declaration.detailRouteCode);
1820
+ const desktopCode = string(detailRouteCode.desktop);
1821
+ const mobileCode = string(detailRouteCode.mobile);
1822
+ if (Object.keys(detailRouteCode).some(key => !['desktop', 'mobile'].includes(key)) ||
1823
+ !OPERATION_CODE_PATTERN.test(desktopCode) ||
1824
+ !OPERATION_CODE_PATTERN.test(mobileCode) ||
1825
+ desktopCode === mobileCode) {
1826
+ diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_DETAIL_ROUTE_INVALID', 'detailRouteCode 必须声明两个不同且合法的 desktop/mobile route code', `${path}.detailRouteCode`));
1827
+ }
1828
+ else {
1829
+ for (const [device, code, surface] of [
1830
+ ['desktop', desktopCode, 'admin'],
1831
+ ['mobile', mobileCode, 'user'],
1832
+ ]) {
1833
+ const route = frontendRoutesByCode.get(code);
1834
+ const routePointer = `${path}.detailRouteCode.${device}`;
1835
+ if (!route) {
1836
+ diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_DETAIL_ROUTE_REFERENCE_MISSING', `detailRouteCode.${device} 必须引用已声明 frontend route`, routePointer));
1837
+ continue;
1838
+ }
1839
+ if (string(route.surface) !== surface) {
1840
+ diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_DETAIL_ROUTE_SURFACE_INVALID', `detailRouteCode.${device} 必须引用 ${surface} surface`, routePointer));
1841
+ }
1842
+ const parameters = [
1843
+ ...string(route.path).matchAll(/:([A-Za-z][A-Za-z0-9_]*)/g),
1844
+ ].map(match => match[1]);
1845
+ if (string(route.path).includes('*') ||
1846
+ parameters.length !== 1 ||
1847
+ parameters[0] !== 'instanceId') {
1848
+ diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_DETAIL_ROUTE_PATH_INVALID', `detailRouteCode.${device} 路由必须且只能声明 :instanceId 动态参数`, routePointer));
1849
+ }
1850
+ }
1851
+ }
1852
+ }
1633
1853
  if (!Number.isInteger(version) || version < 1) {
1634
1854
  diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_VERSION_INVALID', 'Workflow definition version 必须是正整数', `${path}.version`));
1635
1855
  }
1856
+ if (string(object(declaration.definition).title) ===
1857
+ string(object(declaration.definition).code)) {
1858
+ diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_USER_TITLE_REQUIRED', 'Workflow title 必须是用户可读本地化名称,不能等于内部 code', `${path}.definition.title`));
1859
+ }
1636
1860
  for (const error of validateWorkflowDefinition(definition)) {
1637
1861
  diagnostics.push(diagnostic('APP_CONFIG_WORKFLOW_DEFINITION_INVALID', error, path));
1638
1862
  }
@@ -1892,7 +2116,6 @@ function validateFrontendRoutes(rawRoutes, diagnostics) {
1892
2116
  'path',
1893
2117
  'label',
1894
2118
  'surface',
1895
- 'navigation',
1896
2119
  'parentCode',
1897
2120
  'capability',
1898
2121
  'access',
@@ -1932,8 +2155,6 @@ function validateFrontendRoutes(rawRoutes, diagnostics) {
1932
2155
  paths.has(routePath) ||
1933
2156
  !string(route.label) ||
1934
2157
  !['admin', 'user'].includes(string(route.surface)) ||
1935
- (route.navigation !== undefined &&
1936
- !['menu', 'hidden'].includes(string(route.navigation))) ||
1937
2158
  (route.capability !== undefined &&
1938
2159
  !CAPABILITY_PATTERN.test(string(route.capability))) ||
1939
2160
  accessInvalid ||
@@ -1968,6 +2189,160 @@ function validateFrontendRoutes(rawRoutes, diagnostics) {
1968
2189
  }
1969
2190
  }
1970
2191
  }
2192
+ const ADMIN_NAVIGATION_ICONS = new Set([
2193
+ 'overview',
2194
+ 'database',
2195
+ 'operations',
2196
+ 'workflow',
2197
+ 'members',
2198
+ 'calendar',
2199
+ 'document',
2200
+ 'folder',
2201
+ 'settings',
2202
+ ]);
2203
+ function validAdminOrder(value) {
2204
+ return (value === undefined ||
2205
+ (Number.isSafeInteger(value) && Math.abs(Number(value)) <= 10_000));
2206
+ }
2207
+ function validateAdminNavigation(config, diagnostics) {
2208
+ const frontend = object(config.frontend);
2209
+ const admin = object(frontend.admin);
2210
+ const rawNavigation = admin.navigation;
2211
+ if (frontend.admin !== undefined &&
2212
+ (Object.keys(admin).some(key => key !== 'navigation') ||
2213
+ !Array.isArray(rawNavigation))) {
2214
+ diagnostics.push(diagnostic('APP_CONFIG_ADMIN_NAVIGATION_INVALID', 'frontend.admin 只接受 navigation 数组', 'frontend.admin.navigation'));
2215
+ return;
2216
+ }
2217
+ const groups = Array.isArray(rawNavigation) ? rawNavigation : [];
2218
+ if (groups.length > 100) {
2219
+ diagnostics.push(diagnostic('APP_CONFIG_ADMIN_NAVIGATION_LIMIT_EXCEEDED', '后台导航最多包含 100 个业务分组', 'frontend.admin.navigation'));
2220
+ }
2221
+ const resources = new Map((config.data?.resources || []).map(resource => [resource.code, resource]));
2222
+ const routes = new Map((config.frontend.routes || []).map(route => [route.code, route]));
2223
+ const workflows = new Map();
2224
+ const workflowDefinitionsByCode = new Map();
2225
+ for (const declaration of config.workflows?.definitions || []) {
2226
+ const declarations = workflowDefinitionsByCode.get(declaration.definition.code) || [];
2227
+ declarations.push(declaration);
2228
+ workflowDefinitionsByCode.set(declaration.definition.code, declarations);
2229
+ }
2230
+ for (const [code, declarations] of workflowDefinitionsByCode) {
2231
+ const activeVersion = config.workflows?.activations.find(activation => activation.workflowCode === code)?.definitionVersion;
2232
+ const selected = declarations.find(declaration => declaration.version === activeVersion) ||
2233
+ [...declarations].sort((left, right) => right.version - left.version)[0];
2234
+ if (selected) {
2235
+ workflows.set(code, {
2236
+ title: selected.definition.title,
2237
+ mode: selected.launch?.mode || 'work-center-only',
2238
+ });
2239
+ }
2240
+ }
2241
+ const groupCodes = new Set();
2242
+ const pageKeys = new Set();
2243
+ let itemCount = 0;
2244
+ groups.forEach((rawGroup, groupIndex) => {
2245
+ const group = object(rawGroup);
2246
+ const path = `frontend.admin.navigation[${groupIndex}]`;
2247
+ const code = string(group.code);
2248
+ const label = string(group.label);
2249
+ const items = Array.isArray(group.items) ? group.items : [];
2250
+ if (Object.keys(group).some(key => !['code', 'label', 'icon', 'order', 'items'].includes(key)) ||
2251
+ !OPERATION_CODE_PATTERN.test(code) ||
2252
+ groupCodes.has(code) ||
2253
+ !label ||
2254
+ label === code ||
2255
+ (group.icon !== undefined &&
2256
+ !ADMIN_NAVIGATION_ICONS.has(string(group.icon))) ||
2257
+ !validAdminOrder(group.order)) {
2258
+ diagnostics.push(diagnostic('APP_CONFIG_ADMIN_GROUP_INVALID', '导航分组必须声明唯一 code、用户 label、受支持 icon/order 和 items', path));
2259
+ }
2260
+ if (items.length === 0) {
2261
+ diagnostics.push(diagnostic('APP_CONFIG_ADMIN_GROUP_ORPHANED', '导航分组必须至少引用一个可见页面', `${path}.items`));
2262
+ }
2263
+ groupCodes.add(code);
2264
+ items.forEach((rawItem, itemIndex) => {
2265
+ itemCount += 1;
2266
+ const item = object(rawItem);
2267
+ const itemPath = `${path}.items[${itemIndex}]`;
2268
+ const page = object(item.page);
2269
+ const kind = string(page.kind);
2270
+ let pageKey = '';
2271
+ let defaultLabel = '';
2272
+ let internalCode = '';
2273
+ let exists = false;
2274
+ let standaloneViolation = false;
2275
+ if (kind === 'resource') {
2276
+ const resourceCode = string(page.resourceCode);
2277
+ const resource = resources.get(resourceCode);
2278
+ pageKey = `resource:${resourceCode}:list`;
2279
+ defaultLabel = resource?.name || '';
2280
+ internalCode = resourceCode;
2281
+ exists = Boolean(resource?.surface?.generated?.list);
2282
+ }
2283
+ else if (kind === 'operation') {
2284
+ const routeCode = string(page.routeCode);
2285
+ const route = routes.get(routeCode);
2286
+ pageKey = `operation:${routeCode}`;
2287
+ defaultLabel = route?.label || '';
2288
+ internalCode = routeCode;
2289
+ exists = Boolean(route && route.surface === 'admin' && !/[:*]/.test(route.path));
2290
+ }
2291
+ else if (kind === 'application-todo-center') {
2292
+ pageKey = 'application:todo-center';
2293
+ defaultLabel = '待办中心';
2294
+ internalCode = pageKey;
2295
+ exists = true;
2296
+ }
2297
+ else if (kind === 'workflow-work-center') {
2298
+ pageKey = 'workflow:work-center';
2299
+ defaultLabel = '我的审批';
2300
+ internalCode = pageKey;
2301
+ exists = workflows.size > 0;
2302
+ }
2303
+ else if (kind === 'workflow-launch') {
2304
+ const workflowCode = string(page.workflowCode);
2305
+ const workflow = workflows.get(workflowCode);
2306
+ pageKey = `workflow:${workflowCode}:launch`;
2307
+ defaultLabel = workflow ? `发起${workflow.title}` : '';
2308
+ internalCode = workflowCode;
2309
+ standaloneViolation = Boolean(workflow && workflow.mode !== 'standalone');
2310
+ exists = Boolean(workflow && workflow.mode === 'standalone');
2311
+ }
2312
+ if (Object.keys(item).some(key => !['page', 'label', 'icon', 'order'].includes(key)) ||
2313
+ ![
2314
+ 'resource',
2315
+ 'operation',
2316
+ 'application-todo-center',
2317
+ 'workflow-work-center',
2318
+ 'workflow-launch',
2319
+ ].includes(kind) ||
2320
+ (item.icon !== undefined &&
2321
+ !ADMIN_NAVIGATION_ICONS.has(string(item.icon))) ||
2322
+ !validAdminOrder(item.order)) {
2323
+ diagnostics.push(diagnostic('APP_CONFIG_ADMIN_ITEM_INVALID', '导航项必须使用受支持的页面 helper、label、icon 和 order', itemPath));
2324
+ }
2325
+ if (standaloneViolation) {
2326
+ diagnostics.push(diagnostic('APP_CONFIG_ADMIN_WORKFLOW_NOT_STANDALONE', '只有 self-contained standalone Workflow launch page 可以进入菜单', `${itemPath}.page`));
2327
+ }
2328
+ else if (!exists) {
2329
+ diagnostics.push(diagnostic('APP_CONFIG_ADMIN_PAGE_NOT_FOUND', '导航引用必须指向存在且可进入菜单的 page registry 页面', `${itemPath}.page`));
2330
+ }
2331
+ if (pageKey && pageKeys.has(pageKey)) {
2332
+ diagnostics.push(diagnostic('APP_CONFIG_ADMIN_PAGE_DUPLICATE', `同一页面不能重复出现在导航中: ${pageKey}`, `${itemPath}.page`));
2333
+ }
2334
+ if (pageKey)
2335
+ pageKeys.add(pageKey);
2336
+ const label = string(item.label) || defaultLabel;
2337
+ if (!label || label === internalCode || label === pageKey) {
2338
+ diagnostics.push(diagnostic('APP_CONFIG_ADMIN_USER_LABEL_REQUIRED', '导航页面必须解析为用户可读 label,不能泄漏内部 code', item.label === undefined ? `${itemPath}.page` : `${itemPath}.label`));
2339
+ }
2340
+ });
2341
+ });
2342
+ if (itemCount > 500) {
2343
+ diagnostics.push(diagnostic('APP_CONFIG_ADMIN_NAVIGATION_LIMIT_EXCEEDED', '后台导航最多包含 500 个页面引用', 'frontend.admin.navigation'));
2344
+ }
2345
+ }
1971
2346
  function validateAuthorizationTransitions(rawTransitions, _roleCodes, diagnostics) {
1972
2347
  if (rawTransitions !== undefined && !Array.isArray(rawTransitions)) {
1973
2348
  diagnostics.push(diagnostic('APP_CONFIG_AUTHZ_TRANSITIONS_INVALID', 'authz.authorizationTransitions 必须是数组', 'authz.authorizationTransitions'));
@@ -2161,6 +2536,15 @@ export function resourceReadPolicy(input) {
2161
2536
  }
2162
2537
  export function materializeDataResource(appCode, declaration) {
2163
2538
  const capabilities = resourceCapabilityCodes(appCode, declaration.code);
2539
+ const mutationOwner = declaration.mutationOwner || 'native';
2540
+ const nativeMutations = mutationOwner === 'native';
2541
+ const generated = {
2542
+ list: declaration.generated?.list ?? true,
2543
+ detail: declaration.generated?.detail ?? true,
2544
+ create: declaration.generated?.create ?? nativeMutations,
2545
+ update: declaration.generated?.update ?? nativeMutations,
2546
+ delete: declaration.generated?.delete ?? nativeMutations,
2547
+ };
2164
2548
  const fields = declaration.fields.map(field => {
2165
2549
  const definition = {
2166
2550
  code: field.code,
@@ -2176,6 +2560,8 @@ export function materializeDataResource(appCode, declaration) {
2176
2560
  ...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {}),
2177
2561
  ...(field.precision !== undefined ? { precision: field.precision } : {}),
2178
2562
  ...(field.scale !== undefined ? { scale: field.scale } : {}),
2563
+ ...(field.min !== undefined ? { min: field.min } : {}),
2564
+ ...(field.max !== undefined ? { max: field.max } : {}),
2179
2565
  ...(field.timePrecision ? { timePrecision: field.timePrecision } : {}),
2180
2566
  ...(field.file ? { file: field.file } : {}),
2181
2567
  ...(field.serial ? { serial: field.serial } : {}),
@@ -2194,8 +2580,13 @@ export function materializeDataResource(appCode, declaration) {
2194
2580
  .filter(field => field.filter === true)
2195
2581
  .map(field => field.code);
2196
2582
  const access = (field, operation) => {
2197
- if (isGeneratedField(field.type) && operation !== 'read')
2583
+ if (operation !== 'read' &&
2584
+ !supportsGeneratedMutation({
2585
+ type: field.type,
2586
+ system: field.system,
2587
+ })) {
2198
2588
  return [];
2589
+ }
2199
2590
  const override = field.access?.[operation];
2200
2591
  if (override === false)
2201
2592
  return [];
@@ -2204,7 +2595,8 @@ export function materializeDataResource(appCode, declaration) {
2204
2595
  return [capabilities[operation]];
2205
2596
  };
2206
2597
  const surface = {
2207
- generated: true,
2598
+ mutationOwner,
2599
+ generated,
2208
2600
  fields: Object.fromEntries(declaration.fields.map(field => [
2209
2601
  field.code,
2210
2602
  {
@@ -2220,6 +2612,8 @@ export function materializeDataResource(appCode, declaration) {
2220
2612
  ...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {}),
2221
2613
  ...(field.precision !== undefined ? { precision: field.precision } : {}),
2222
2614
  ...(field.scale !== undefined ? { scale: field.scale } : {}),
2615
+ ...(field.min !== undefined ? { min: field.min } : {}),
2616
+ ...(field.max !== undefined ? { max: field.max } : {}),
2223
2617
  ...(field.file?.maxCount !== undefined
2224
2618
  ? { maxCount: field.file.maxCount }
2225
2619
  : {}),
@@ -2267,6 +2661,7 @@ export function materializeDataResource(appCode, declaration) {
2267
2661
  code: declaration.code,
2268
2662
  name: declaration.name,
2269
2663
  schema: { fields },
2664
+ ...(declaration.invariants ? { invariants: declaration.invariants } : {}),
2270
2665
  surface,
2271
2666
  capabilities,
2272
2667
  ...(declaration.dataPolicyCode
@@ -2292,7 +2687,10 @@ export function validateAppDeclaration(value) {
2292
2687
  const resourceKeys = new Set([
2293
2688
  'code',
2294
2689
  'name',
2690
+ 'mutationOwner',
2691
+ 'generated',
2295
2692
  'fields',
2693
+ 'invariants',
2296
2694
  'list',
2297
2695
  'form',
2298
2696
  'detail',
@@ -2310,6 +2708,8 @@ export function validateAppDeclaration(value) {
2310
2708
  'maxLength',
2311
2709
  'precision',
2312
2710
  'scale',
2711
+ 'min',
2712
+ 'max',
2313
2713
  'timePrecision',
2314
2714
  'file',
2315
2715
  'serial',
@@ -2332,6 +2732,26 @@ export function validateAppDeclaration(value) {
2332
2732
  }
2333
2733
  }
2334
2734
  const fields = Array.isArray(resource.fields) ? resource.fields : [];
2735
+ const mutationOwner = string(resource.mutationOwner) || 'native';
2736
+ const generated = object(resource.generated);
2737
+ if (!['native', 'action', 'readonly', 'workflow'].includes(mutationOwner)) {
2738
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_MUTATION_OWNER_INVALID', 'mutationOwner 必须是 native、action、readonly 或 workflow', `${resourcePath}.mutationOwner`));
2739
+ }
2740
+ for (const key of Object.keys(generated)) {
2741
+ if (!['list', 'detail', 'create', 'update', 'delete'].includes(key)) {
2742
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_GENERATED_SURFACE_INVALID', `generated.${key} 不是受支持的标准页面/操作`, `${resourcePath}.generated.${key}`));
2743
+ }
2744
+ else if (typeof generated[key] !== 'boolean') {
2745
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_GENERATED_SURFACE_INVALID', `generated.${key} 必须是 boolean`, `${resourcePath}.generated.${key}`));
2746
+ }
2747
+ }
2748
+ if (mutationOwner !== 'native') {
2749
+ for (const operation of ['create', 'update', 'delete']) {
2750
+ if (generated[operation] === true) {
2751
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_NATIVE_MUTATION_OWNER_CONFLICT', `${mutationOwner} owns mutations; Native ${operation} cannot be generated`, `${resourcePath}.generated.${operation}`));
2752
+ }
2753
+ }
2754
+ }
2335
2755
  if (fields.length === 0) {
2336
2756
  diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_FIELDS_REQUIRED', '资源必须直接声明非空 fields 数组', `${resourcePath}.fields`));
2337
2757
  return;
@@ -2414,6 +2834,21 @@ export function validateAppDeclaration(value) {
2414
2834
  if (code)
2415
2835
  declaredCodes.add(code);
2416
2836
  });
2837
+ for (const operation of ['create', 'update']) {
2838
+ const enabled = generated[operation] ?? mutationOwner === 'native';
2839
+ const writable = fields.some(rawField => {
2840
+ const field = object(rawField);
2841
+ return (APP_DATA_FIELD_TYPES.has(string(field.type)) &&
2842
+ supportsGeneratedMutation({
2843
+ type: string(field.type),
2844
+ system: field.system === true,
2845
+ }) &&
2846
+ object(field.access)[operation] !== false);
2847
+ });
2848
+ if (materializable && enabled && !writable) {
2849
+ diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_WRITABLE_FIELDS_REQUIRED', `generated.${operation} 需要至少一个可写业务字段`, `${resourcePath}.generated.${operation}`));
2850
+ }
2851
+ }
2417
2852
  const defaultSortField = string(object(resource.list).defaultSort && object(object(resource.list).defaultSort).field);
2418
2853
  if (defaultSortField && !declaredCodes.has(defaultSortField)) {
2419
2854
  diagnostics.push(diagnostic('APP_CONFIG_DATA_RESOURCE_SORT_FIELD_INVALID', '默认排序字段必须在同一资源 fields 中声明', `${resourcePath}.list.defaultSort.field`));