openxiangda 1.0.49 → 1.0.51

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 (47) hide show
  1. package/lib/cli.js +339 -9
  2. package/lib/workspace-init.js +20 -8
  3. package/openxiangda-skills/SKILL.md +4 -3
  4. package/openxiangda-skills/skills/openxiangda-app/SKILL.md +28 -0
  5. package/openxiangda-skills/skills/openxiangda-core/SKILL.md +45 -1
  6. package/openxiangda-skills/skills/openxiangda-permission-settings/SKILL.md +31 -0
  7. package/package.json +7 -1
  8. package/packages/sdk/dist/runtime/index.cjs +3614 -3377
  9. package/packages/sdk/dist/runtime/index.cjs.map +1 -1
  10. package/packages/sdk/dist/runtime/index.d.mts +1 -0
  11. package/packages/sdk/dist/runtime/index.d.ts +1 -0
  12. package/packages/sdk/dist/runtime/index.mjs +3103 -2860
  13. package/packages/sdk/dist/runtime/index.mjs.map +1 -1
  14. package/packages/sdk/dist/runtime/react.cjs +236 -0
  15. package/packages/sdk/dist/runtime/react.cjs.map +1 -0
  16. package/packages/sdk/dist/runtime/react.d.mts +109 -0
  17. package/packages/sdk/dist/runtime/react.d.ts +109 -0
  18. package/packages/sdk/dist/runtime/react.mjs +222 -0
  19. package/packages/sdk/dist/runtime/react.mjs.map +1 -0
  20. package/templates/openxiangda-react-spa/.env.example +4 -0
  21. package/templates/openxiangda-react-spa/AGENTS.md +65 -0
  22. package/templates/openxiangda-react-spa/index.html +12 -0
  23. package/templates/openxiangda-react-spa/package.json +35 -0
  24. package/templates/openxiangda-react-spa/postcss.config.cjs +6 -0
  25. package/templates/openxiangda-react-spa/src/app/router.tsx +97 -0
  26. package/templates/openxiangda-react-spa/src/layouts/AdminShell.tsx +102 -0
  27. package/templates/openxiangda-react-spa/src/layouts/PublicShell.tsx +11 -0
  28. package/templates/openxiangda-react-spa/src/layouts/UserShell.tsx +22 -0
  29. package/templates/openxiangda-react-spa/src/main.tsx +12 -0
  30. package/templates/openxiangda-react-spa/src/pages/admin/RuntimeWorkspacePage.tsx +57 -0
  31. package/templates/openxiangda-react-spa/src/pages/defaults/DataRoutePage.tsx +17 -0
  32. package/templates/openxiangda-react-spa/src/pages/defaults/FilePreviewRoutePage.tsx +14 -0
  33. package/templates/openxiangda-react-spa/src/pages/defaults/FormRoutePage.tsx +62 -0
  34. package/templates/openxiangda-react-spa/src/pages/portal/UserPortalPage.tsx +27 -0
  35. package/templates/openxiangda-react-spa/src/pages/public/PublicHomePage.tsx +10 -0
  36. package/templates/openxiangda-react-spa/src/pages/states/NotFoundPage.tsx +16 -0
  37. package/templates/openxiangda-react-spa/src/resources/menus/menus.json +31 -0
  38. package/templates/openxiangda-react-spa/src/resources/permissions/page-groups/app-admin.json +8 -0
  39. package/templates/openxiangda-react-spa/src/resources/permissions/page-groups/app-user.json +8 -0
  40. package/templates/openxiangda-react-spa/src/resources/roles/roles.json +14 -0
  41. package/templates/openxiangda-react-spa/src/styles/index.css +23 -0
  42. package/templates/openxiangda-react-spa/tailwind.config.cjs +29 -0
  43. package/templates/openxiangda-react-spa/tsconfig.app.json +36 -0
  44. package/templates/openxiangda-react-spa/tsconfig.json +7 -0
  45. package/templates/openxiangda-react-spa/tsconfig.node.json +10 -0
  46. package/templates/openxiangda-react-spa/vite.config.ts +73 -0
  47. package/templates/sy-lowcode-app-workspace/src/dev/App.tsx +33 -1
package/lib/cli.js CHANGED
@@ -59,6 +59,7 @@ async function main(argv) {
59
59
  if (command === 'permission') return permission(rest);
60
60
  if (command === 'settings') return settings(rest);
61
61
  if (command === 'resource') return resource(rest);
62
+ if (command === 'runtime') return runtime(rest);
62
63
  if (command === 'inspect') return inspect(rest);
63
64
  if (command === 'skill') return skill(rest);
64
65
  if (command === 'feedback') return feedback(rest);
@@ -78,12 +79,12 @@ Usage:
78
79
  openxiangda platform use <name>
79
80
  openxiangda auth status|refresh|logout [--profile name]
80
81
  openxiangda env [--profile name]
81
- openxiangda workspace init [dir] [--name package-name] [--install] [--profile name --app-type APP_XXX]
82
- openxiangda workspace init [dir] --profile <name> --app-name <app-name> [--install]
82
+ openxiangda workspace init [dir] [--name package-name] [--runtime legacy|react-spa] [--install] [--profile name --app-type APP_XXX]
83
+ openxiangda workspace init [dir] --profile <name> --app-name <app-name> [--runtime legacy|react-spa] [--install]
83
84
  openxiangda workspace bind --profile <name> --app-type <APP_XXX>
84
85
  openxiangda workspace publish --profile <name> [--changed [--since ref]|--form code|--page code|--only list] [--dry-run] [--force] [--resources|--skip-resources] [--prune]
85
86
  openxiangda app list [--profile name] [--json]
86
- openxiangda app create <name> [--profile name] [--description text]
87
+ openxiangda app create <name> [--profile name] [--runtime legacy|react-spa] [--description text]
87
88
  openxiangda app snapshot <APP_XXX> [--profile name] [--json]
88
89
  openxiangda form list [--profile name] [--json]
89
90
  openxiangda form create <formCode> [--name text] [--type receipt] # low-level shell only
@@ -113,6 +114,9 @@ Usage:
113
114
  openxiangda permission form-group-list|form-group-create|form-group-bind
114
115
  openxiangda settings get|save|indexes|indexes-save|data-management|data-management-save|public-access
115
116
  openxiangda resource validate|plan|publish|pull [--profile name] [--json]
117
+ openxiangda runtime deploy [--profile name] [--dist dist] [--build-id id] [--no-build] [--no-activate] [--json]
118
+ openxiangda runtime releases [--profile name] [--json]
119
+ openxiangda runtime activate <releaseId> [--profile name] [--json]
116
120
  openxiangda inspect app|form|workflow|automation|permissions
117
121
  openxiangda feedback preview|submit --summary <text> [--type bug] [--severity medium] [--profile name] [--yes]
118
122
  openxiangda skill install [--agent codex|claude|qoder|dual] [--dest <skills-dir>] [--force] [--dry-run] [--json]
@@ -1040,11 +1044,13 @@ async function workspace(args) {
1040
1044
  assertCanInitializeWorkspace({
1041
1045
  dir: positional[0],
1042
1046
  force: flags.force,
1047
+ runtime: flags.runtime || flags.template,
1043
1048
  });
1044
1049
  const data = await createPlatformApp(config, profileName, {
1045
1050
  name: flags['app-name'],
1046
1051
  description: flags.description || '',
1047
1052
  iconfontCss: flags['iconfont-css'] || '',
1053
+ runtimeMode: normalizeWorkspaceRuntimeFlag(flags.runtime || flags.template),
1048
1054
  });
1049
1055
  appType = extractCreatedAppType(data);
1050
1056
  if (!appType) {
@@ -1064,6 +1070,7 @@ async function workspace(args) {
1064
1070
  force: flags.force,
1065
1071
  profile: profileName,
1066
1072
  appType,
1073
+ runtime: flags.runtime || flags.template,
1067
1074
  });
1068
1075
  if (createdApp) {
1069
1076
  result.createdApp = createdApp;
@@ -1167,6 +1174,7 @@ async function app(args) {
1167
1174
  name,
1168
1175
  description: flags.description || '',
1169
1176
  iconfontCss: flags['iconfont-css'] || '',
1177
+ runtimeMode: normalizeWorkspaceRuntimeFlag(flags.runtime || flags.template),
1170
1178
  });
1171
1179
  if (flags.json) return writeJson(data);
1172
1180
  print(JSON.stringify(data, null, 2));
@@ -1194,10 +1202,16 @@ async function createPlatformApp(config, profileName, payload) {
1194
1202
  name: payload.name,
1195
1203
  description: payload.description || '',
1196
1204
  iconfontCss: payload.iconfontCss || '',
1205
+ ...(payload.runtimeMode ? { runtimeMode: payload.runtimeMode } : {}),
1206
+ ...(payload.runtimeSettings ? { runtimeSettings: payload.runtimeSettings } : {}),
1197
1207
  },
1198
1208
  });
1199
1209
  }
1200
1210
 
1211
+ function normalizeWorkspaceRuntimeFlag(value) {
1212
+ return value === 'react-spa' || value === 'spa' ? 'react-spa' : undefined;
1213
+ }
1214
+
1201
1215
  function extractCreatedAppType(data) {
1202
1216
  const candidates = [
1203
1217
  data?.appType,
@@ -2215,11 +2229,14 @@ async function permission(args) {
2215
2229
  const [groupCode] = positional;
2216
2230
  const name = flags.name || positional.slice(1).join(' ') || groupCode;
2217
2231
  if (!groupCode || !name) {
2218
- fail('用法: openxiangda permission page-group-create <groupCode> --name <text> [--menu-codes <menuCode...>|--page-codes <pageCode...>|--form-codes <formCode...>|--menu-ids <id...>] [--no-runtime-aliases]');
2232
+ fail('用法: openxiangda permission page-group-create <groupCode> --name <text> [--menu-codes <menuCode...>|--route-codes <routeCode...>|--path-patterns <pattern...>|--page-codes <pageCode...>|--form-codes <formCode...>|--menu-ids <id...>] [--no-runtime-aliases]');
2219
2233
  }
2220
2234
  const target = getWorkspaceTarget(config, profileName, flags);
2221
2235
  const roles = splitList(flags.roles);
2222
2236
  const editableTargets = resolvePagePermissionMenuTargets(target.bound, flags);
2237
+ const menuCodes = splitList(flags['menu-codes']);
2238
+ const routeCodes = splitList(flags['route-codes']);
2239
+ const pathPatterns = splitList(flags['path-patterns']);
2223
2240
  const runtimeAliasTargets = flags['no-runtime-aliases']
2224
2241
  ? []
2225
2242
  : resolvePagePermissionRuntimeAliasTargets(target.bound, flags);
@@ -2233,6 +2250,9 @@ async function permission(args) {
2233
2250
  name,
2234
2251
  roles,
2235
2252
  menuFormUuids: editableTargets,
2253
+ menuCodes,
2254
+ routeCodes,
2255
+ pathPatterns,
2236
2256
  },
2237
2257
  }
2238
2258
  );
@@ -2564,8 +2584,8 @@ async function resource(args) {
2564
2584
  const config = loadConfig();
2565
2585
  const profileName = flags.profile || config.currentProfile;
2566
2586
 
2567
- if (!['validate', 'plan', 'publish', 'pull'].includes(subcommand)) {
2568
- fail('用法: openxiangda resource validate|plan|publish|pull [--profile name] [--json]');
2587
+ if (!['validate', 'plan', 'publish', 'pull', 'typegen'].includes(subcommand)) {
2588
+ fail('用法: openxiangda resource validate|plan|publish|pull|typegen [--profile name] [--json]');
2569
2589
  }
2570
2590
 
2571
2591
  if (subcommand === 'pull') {
@@ -2577,6 +2597,13 @@ async function resource(args) {
2577
2597
  }
2578
2598
 
2579
2599
  const manifest = loadWorkspaceResources();
2600
+ if (subcommand === 'typegen') {
2601
+ const result = generateResourceTypes(manifest, flags.out || flags.output);
2602
+ if (flags.json) return writeJson(result);
2603
+ print(`已生成资源类型: ${result.filePath}`);
2604
+ return;
2605
+ }
2606
+
2580
2607
  const validation = validateWorkspaceResources(manifest);
2581
2608
  if (subcommand === 'validate') {
2582
2609
  if (flags.json) return writeJson(validation);
@@ -2605,6 +2632,226 @@ async function resource(args) {
2605
2632
  printResourceResult(result);
2606
2633
  }
2607
2634
 
2635
+ async function runtime(args) {
2636
+ const [subcommand, ...rest] = args;
2637
+ const { flags, positional } = parseArgs(rest);
2638
+ const config = loadConfig();
2639
+ const profileName = flags.profile || config.currentProfile;
2640
+ const target = getWorkspaceTarget(config, profileName, flags);
2641
+
2642
+ if (subcommand === 'releases' || subcommand === 'list') {
2643
+ const data = await requestWithAuth(
2644
+ config,
2645
+ target.profileName,
2646
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/runtime/releases`
2647
+ );
2648
+ if (flags.json) return writeJson(data);
2649
+ print(JSON.stringify(data, null, 2));
2650
+ return;
2651
+ }
2652
+
2653
+ if (subcommand === 'activate') {
2654
+ const releaseId = positional[0] || flags['release-id'];
2655
+ if (!releaseId) fail('用法: openxiangda runtime activate <releaseId> [--profile name]');
2656
+ const data = await requestWithAuth(
2657
+ config,
2658
+ target.profileName,
2659
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/runtime/releases/${encodeURIComponent(releaseId)}/activate`,
2660
+ { method: 'POST' }
2661
+ );
2662
+ saveRuntimeReleaseState(target, data);
2663
+ if (flags.json) return writeJson(data);
2664
+ print(`runtime release 已激活: ${data.id || releaseId} build=${data.buildId || '-'}`);
2665
+ return;
2666
+ }
2667
+
2668
+ if (subcommand === 'deploy') {
2669
+ const buildId = normalizeRuntimeBuildId(flags['build-id'] || createRuntimeBuildId());
2670
+ const distDir = path.resolve(process.cwd(), flags.dist || 'dist');
2671
+ const assetBaseUrl = buildRuntimeAssetBaseUrl(target.appType, buildId);
2672
+ if (!flags['no-build']) {
2673
+ runRuntimeBuild({
2674
+ buildId,
2675
+ appType: target.appType,
2676
+ assetBaseUrl,
2677
+ command: flags['build-command'],
2678
+ });
2679
+ }
2680
+ const files = collectRuntimeDistFiles(distDir, {
2681
+ includeSourceMaps: Boolean(flags['include-sourcemaps']),
2682
+ });
2683
+ const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
2684
+ const data = await requestWithAuth(
2685
+ config,
2686
+ target.profileName,
2687
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/runtime/releases`,
2688
+ {
2689
+ method: 'POST',
2690
+ body: {
2691
+ buildId,
2692
+ version: flags.version || readWorkspaceVersion(),
2693
+ releaseNotes: flags.notes || flags['release-notes'] || '',
2694
+ activate: !flags['no-activate'],
2695
+ files,
2696
+ },
2697
+ }
2698
+ );
2699
+ saveRuntimeReleaseState(target, data);
2700
+ const result = {
2701
+ appType: target.appType,
2702
+ buildId,
2703
+ release: data,
2704
+ distDir,
2705
+ fileCount: files.length,
2706
+ totalBytes,
2707
+ assetBaseUrl,
2708
+ activated: !flags['no-activate'],
2709
+ };
2710
+ if (flags.json) return writeJson(result);
2711
+ print(
2712
+ [
2713
+ `runtime release 已上传: ${target.appType}`,
2714
+ `build: ${buildId}`,
2715
+ `files: ${files.length} (${formatBytes(totalBytes)})`,
2716
+ `assetBase: ${assetBaseUrl}`,
2717
+ `active: ${result.activated ? 'yes' : 'no'}`,
2718
+ ].join('\n')
2719
+ );
2720
+ return;
2721
+ }
2722
+
2723
+ fail('用法: openxiangda runtime deploy|releases|activate [--profile name]');
2724
+ }
2725
+
2726
+ function runRuntimeBuild(options) {
2727
+ const command = options.command || defaultRuntimeBuildCommand();
2728
+ print(`构建 React SPA runtime: ${command}`);
2729
+ const result = spawnSync(command, [], {
2730
+ cwd: process.cwd(),
2731
+ shell: true,
2732
+ stdio: 'inherit',
2733
+ env: {
2734
+ ...process.env,
2735
+ OPENXIANGDA_APP_TYPE: options.appType,
2736
+ APP_TYPE: options.appType,
2737
+ OPENXIANGDA_BUILD_ID: options.buildId,
2738
+ OPENXIANGDA_RUNTIME_ASSET_BASE: options.assetBaseUrl,
2739
+ },
2740
+ });
2741
+ if (result.error) fail(`runtime build 无法启动: ${result.error.message}`);
2742
+ if (result.status !== 0) fail(`runtime build 失败: exit ${result.status}`);
2743
+ }
2744
+
2745
+ function defaultRuntimeBuildCommand() {
2746
+ if (fs.existsSync(path.join(process.cwd(), 'pnpm-lock.yaml'))) return 'pnpm build';
2747
+ if (fs.existsSync(path.join(process.cwd(), 'yarn.lock'))) return 'yarn build';
2748
+ return 'npm run build';
2749
+ }
2750
+
2751
+ function collectRuntimeDistFiles(distDir, options = {}) {
2752
+ if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) {
2753
+ fail(`runtime dist 目录不存在: ${distDir}`);
2754
+ }
2755
+ const files = [];
2756
+ walkRuntimeDist(distDir, distDir, files, options);
2757
+ if (!files.some(file => file.path === 'index.html')) {
2758
+ fail(`runtime dist 缺少 index.html: ${distDir}`);
2759
+ }
2760
+ const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
2761
+ if (totalBytes > 25 * 1024 * 1024) {
2762
+ fail(`runtime dist 过大: ${formatBytes(totalBytes)},当前 JSON 发布通道上限为 25MB`);
2763
+ }
2764
+ return files;
2765
+ }
2766
+
2767
+ function walkRuntimeDist(rootDir, currentDir, files, options) {
2768
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
2769
+ for (const entry of entries) {
2770
+ const absolute = path.join(currentDir, entry.name);
2771
+ if (entry.isDirectory()) {
2772
+ walkRuntimeDist(rootDir, absolute, files, options);
2773
+ continue;
2774
+ }
2775
+ if (!entry.isFile()) continue;
2776
+ if (!options.includeSourceMaps && entry.name.endsWith('.map')) continue;
2777
+ const relative = path.relative(rootDir, absolute).replace(/\\/g, '/');
2778
+ const buffer = fs.readFileSync(absolute);
2779
+ files.push({
2780
+ path: relative,
2781
+ size: buffer.length,
2782
+ sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
2783
+ contentType: inferRuntimeContentType(relative),
2784
+ contentBase64: buffer.toString('base64'),
2785
+ });
2786
+ }
2787
+ }
2788
+
2789
+ function buildRuntimeAssetBaseUrl(appType, buildId) {
2790
+ return `/service/openxiangda-api/v1/apps/${encodeURIComponent(appType)}/runtime/releases/by-build/${encodeURIComponent(buildId)}/files/`;
2791
+ }
2792
+
2793
+ function createRuntimeBuildId() {
2794
+ const stamp = new Date()
2795
+ .toISOString()
2796
+ .replace(/[-:.TZ]/g, '')
2797
+ .slice(0, 14);
2798
+ return `${stamp}-${crypto.randomBytes(4).toString('hex')}`;
2799
+ }
2800
+
2801
+ function normalizeRuntimeBuildId(value) {
2802
+ const normalized = String(value || '').trim().replace(/[^a-zA-Z0-9_.:-]/g, '-');
2803
+ if (!normalized) fail('buildId 不能为空');
2804
+ return normalized.slice(0, 128);
2805
+ }
2806
+
2807
+ function inferRuntimeContentType(filePath) {
2808
+ const extension = String(filePath || '').split('.').pop().toLowerCase();
2809
+ if (extension === 'html') return 'text/html; charset=utf-8';
2810
+ if (extension === 'js' || extension === 'mjs') return 'application/javascript; charset=utf-8';
2811
+ if (extension === 'css') return 'text/css; charset=utf-8';
2812
+ if (extension === 'json') return 'application/json; charset=utf-8';
2813
+ if (extension === 'svg') return 'image/svg+xml';
2814
+ if (extension === 'png') return 'image/png';
2815
+ if (extension === 'jpg' || extension === 'jpeg') return 'image/jpeg';
2816
+ if (extension === 'webp') return 'image/webp';
2817
+ if (extension === 'woff') return 'font/woff';
2818
+ if (extension === 'woff2') return 'font/woff2';
2819
+ return 'application/octet-stream';
2820
+ }
2821
+
2822
+ function readWorkspaceVersion() {
2823
+ const packageFile = path.join(process.cwd(), 'package.json');
2824
+ if (!fs.existsSync(packageFile)) return '';
2825
+ try {
2826
+ const pkg = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
2827
+ return String(pkg.version || '');
2828
+ } catch {
2829
+ return '';
2830
+ }
2831
+ }
2832
+
2833
+ function saveRuntimeReleaseState(target, release) {
2834
+ target.bound.runtime = {
2835
+ ...(target.bound.runtime || {}),
2836
+ activeReleaseId: release?.id || target.bound.runtime?.activeReleaseId || null,
2837
+ activeBuildId: release?.buildId || target.bound.runtime?.activeBuildId || null,
2838
+ assetBaseUrl: release?.assetBaseUrl || target.bound.runtime?.assetBaseUrl || null,
2839
+ updatedAt: new Date().toISOString(),
2840
+ };
2841
+ target.bound.updatedAt = new Date().toISOString();
2842
+ if (target.state.profiles?.[target.profileName]) {
2843
+ target.state.profiles[target.profileName] = target.bound;
2844
+ }
2845
+ saveProjectState(target.state);
2846
+ }
2847
+
2848
+ function formatBytes(value) {
2849
+ const bytes = Number(value || 0);
2850
+ if (bytes < 1024) return `${bytes}B`;
2851
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
2852
+ return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
2853
+ }
2854
+
2608
2855
  async function inspect(args) {
2609
2856
  const [subcommand, ...rest] = args;
2610
2857
  const { flags, positional } = parseArgs(rest);
@@ -2705,7 +2952,7 @@ async function commands(args) {
2705
2952
  'permission page-group-list|page-group-create|page-group-bind',
2706
2953
  'permission form-group-list|form-group-create|form-group-bind|form-summary|menu-permissions',
2707
2954
  'settings get|save|indexes|indexes-save|data-management|data-management-save|public-access|public-access-save|public-access-delete',
2708
- 'resource validate|plan|publish|pull',
2955
+ 'resource validate|plan|publish|pull|typegen',
2709
2956
  'inspect app|form|workflow|automation|permissions',
2710
2957
  'feedback preview|submit',
2711
2958
  'skill install|status|bootstrap',
@@ -3055,6 +3302,8 @@ function saveMenuResource(target, menuCode, menuId, extra = {}) {
3055
3302
  'routeKey',
3056
3303
  'legacyFormUuid',
3057
3304
  'parentId',
3305
+ 'routeCode',
3306
+ 'path',
3058
3307
  ];
3059
3308
  saveStateResource(target, 'menus', menuCode, {
3060
3309
  ...pickStateFields(extra, keys),
@@ -3359,6 +3608,57 @@ function validateWorkspaceResources(manifest) {
3359
3608
  };
3360
3609
  }
3361
3610
 
3611
+ function generateResourceTypes(manifest, outputFile) {
3612
+ const filePath = path.resolve(outputFile || path.join('src', 'generated', 'openxiangda.gen.ts'));
3613
+ const menus = (manifest.menus || []).map(item => ({
3614
+ code: item.code,
3615
+ routeCode: item.routeCode || null,
3616
+ path: item.path || null,
3617
+ }));
3618
+ const pagePermissionGroups = (manifest.pagePermissionGroups || []).map(item => ({
3619
+ code: item.code,
3620
+ menuCodes: normalizePermissionCodeArray(item.menuCodes),
3621
+ routeCodes: normalizePermissionCodeArray(item.routeCodes),
3622
+ pathPatterns: normalizePermissionCodeArray(item.pathPatterns),
3623
+ }));
3624
+ const routeCodes = unique(
3625
+ [
3626
+ ...menus.map(item => item.routeCode),
3627
+ ...pagePermissionGroups.flatMap(item => item.routeCodes),
3628
+ ].filter(Boolean)
3629
+ );
3630
+ const menuCodes = unique(menus.map(item => item.code).filter(Boolean));
3631
+ const pagePermissionGroupCodes = unique(
3632
+ pagePermissionGroups.map(item => item.code).filter(Boolean)
3633
+ );
3634
+ const content = [
3635
+ '/* eslint-disable */',
3636
+ '// Generated by openxiangda resource typegen. Do not edit manually.',
3637
+ '',
3638
+ `export const menuCodes = ${JSON.stringify(menuCodes, null, 2)} as const`,
3639
+ 'export type MenuCode = typeof menuCodes[number]',
3640
+ '',
3641
+ `export const routeCodes = ${JSON.stringify(routeCodes, null, 2)} as const`,
3642
+ 'export type RouteCode = typeof routeCodes[number]',
3643
+ '',
3644
+ `export const pagePermissionGroupCodes = ${JSON.stringify(pagePermissionGroupCodes, null, 2)} as const`,
3645
+ 'export type PagePermissionGroupCode = typeof pagePermissionGroupCodes[number]',
3646
+ '',
3647
+ `export const runtimeMenus = ${JSON.stringify(menus, null, 2)} as const`,
3648
+ '',
3649
+ `export const pagePermissionGroups = ${JSON.stringify(pagePermissionGroups, null, 2)} as const`,
3650
+ '',
3651
+ ].join('\n');
3652
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
3653
+ fs.writeFileSync(filePath, content);
3654
+ return {
3655
+ filePath,
3656
+ menuCodes: menuCodes.length,
3657
+ routeCodes: routeCodes.length,
3658
+ pagePermissionGroups: pagePermissionGroupCodes.length,
3659
+ };
3660
+ }
3661
+
3362
3662
  function validateResourceItem(kind, item, errors, warnings) {
3363
3663
  const label = resourceLabel(kind, item);
3364
3664
  if (item.__invalid) {
@@ -4259,6 +4559,8 @@ async function publishMenuResources(config, target, menus, result) {
4259
4559
  sortOrder: menuItem.sortOrder,
4260
4560
  icon: menuItem.icon || null,
4261
4561
  isHidden: menuItem.isHidden,
4562
+ routeCode: menuItem.routeCode || null,
4563
+ path: menuItem.path || null,
4262
4564
  };
4263
4565
  const data = existing
4264
4566
  ? await requestWithAuth(
@@ -4281,6 +4583,10 @@ async function publishMenuResources(config, target, menus, result) {
4281
4583
  ...(formUuid ? { formUuid } : {}),
4282
4584
  ...(pageId ? { pageId } : {}),
4283
4585
  ...(menuItem.pageCode ? { pageCode: menuItem.pageCode } : {}),
4586
+ ...(menuItem.routeCode || data.routeCode
4587
+ ? { routeCode: data.routeCode || menuItem.routeCode }
4588
+ : {}),
4589
+ ...(menuItem.path || data.path ? { path: data.path || menuItem.path } : {}),
4284
4590
  });
4285
4591
  }
4286
4592
  result.published.push({ kind: 'menu', code: menuItem.code, action: existing ? 'update' : 'create', id: data?.id });
@@ -4615,6 +4921,9 @@ async function publishPagePermissionGroupResources(config, target, groups, resul
4615
4921
  name: group.name || group.code,
4616
4922
  roles: group.roles || [],
4617
4923
  menuFormUuids: resolvePagePermissionGroupTargets(target.bound, group),
4924
+ menuCodes: normalizePermissionCodeArray(group.menuCodes),
4925
+ routeCodes: normalizePermissionCodeArray(group.routeCodes),
4926
+ pathPatterns: normalizePermissionCodeArray(group.pathPatterns),
4618
4927
  };
4619
4928
  const data = existing
4620
4929
  ? await requestWithAuth(
@@ -5174,6 +5483,9 @@ async function pullResources(config, target) {
5174
5483
  name: group.name,
5175
5484
  roles: group.roles || [],
5176
5485
  ...targets,
5486
+ ...arrayField('menuCodes', group.menuCodes),
5487
+ ...arrayField('routeCodes', group.routeCodes),
5488
+ ...arrayField('pathPatterns', group.pathPatterns),
5177
5489
  });
5178
5490
  written.push(path.relative(process.cwd(), filePath));
5179
5491
  }
@@ -5288,6 +5600,11 @@ function splitPagePermissionTargetsForManifest(values = [], lookups) {
5288
5600
  };
5289
5601
  }
5290
5602
 
5603
+ function arrayField(key, value) {
5604
+ const items = normalizePermissionCodeArray(value);
5605
+ return items.length > 0 ? { [key]: items } : {};
5606
+ }
5607
+
5291
5608
  function toPulledNotificationTemplate(template, lookups) {
5292
5609
  const formCode = lookups.formCodeByUuid.get(template.formUuid);
5293
5610
  return stripUndefinedValues({
@@ -5666,6 +5983,14 @@ function resolvePagePermissionGroupTargets(bound, group) {
5666
5983
  return unique([...direct, ...fromMenus, ...fromForms, ...fromPages].filter(Boolean));
5667
5984
  }
5668
5985
 
5986
+ function normalizePermissionCodeArray(value) {
5987
+ return unique(
5988
+ (Array.isArray(value) ? value : [])
5989
+ .map(item => String(item || '').trim())
5990
+ .filter(Boolean)
5991
+ );
5992
+ }
5993
+
5669
5994
  function saveResourceEntry(target, bucket, code, extra = {}) {
5670
5995
  saveStateResource(target, bucket, code, pickStateFields(extra, ['formUuid']), [
5671
5996
  'formUuid',
@@ -5702,7 +6027,9 @@ function menuEquals(bound, desired, existing) {
5702
6027
  optionalScalarEquals(existing.parentId || null, desiredParentId, hasAnyKey(desired, ['parentCode', 'parentId'])) &&
5703
6028
  optionalScalarEquals(existing.sortOrder, desired.sortOrder, desired.sortOrder !== undefined) &&
5704
6029
  optionalScalarEquals(existing.icon || null, desired.icon || null, desired.icon !== undefined) &&
5705
- optionalScalarEquals(Boolean(existing.isHidden), Boolean(desired.isHidden), desired.isHidden !== undefined)
6030
+ optionalScalarEquals(Boolean(existing.isHidden), Boolean(desired.isHidden), desired.isHidden !== undefined) &&
6031
+ optionalScalarEquals(existing.routeCode || null, desired.routeCode || null, desired.routeCode !== undefined) &&
6032
+ optionalScalarEquals(existing.path || null, desired.path || null, desired.path !== undefined)
5706
6033
  );
5707
6034
  }
5708
6035
 
@@ -5754,7 +6081,10 @@ function pagePermissionGroupEquals(bound, desired, existing) {
5754
6081
  String(existing.name || '') === String(desired.name || desired.code || '') &&
5755
6082
  String(existing.resourceCode || '') === String(desired.code || '') &&
5756
6083
  stringSetEquals(existing.roles || [], desired.roles || []) &&
5757
- stringSetEquals(existing.menuFormUuids || [], desiredTargets)
6084
+ stringSetEquals(existing.menuFormUuids || [], desiredTargets) &&
6085
+ stringSetEquals(existing.menuCodes || [], normalizePermissionCodeArray(desired.menuCodes)) &&
6086
+ stringSetEquals(existing.routeCodes || [], normalizePermissionCodeArray(desired.routeCodes)) &&
6087
+ stringSetEquals(existing.pathPatterns || [], normalizePermissionCodeArray(desired.pathPatterns))
5758
6088
  );
5759
6089
  }
5760
6090
 
@@ -4,7 +4,8 @@ const { spawnSync } = require('child_process');
4
4
  const { getProfile, loadConfig, saveProjectState } = require('./config');
5
5
 
6
6
  const ROOT_DIR = path.join(__dirname, '..');
7
- const TEMPLATE_DIR = path.join(ROOT_DIR, 'templates', 'sy-lowcode-app-workspace');
7
+ const LEGACY_TEMPLATE_DIR = path.join(ROOT_DIR, 'templates', 'sy-lowcode-app-workspace');
8
+ const REACT_SPA_TEMPLATE_DIR = path.join(ROOT_DIR, 'templates', 'openxiangda-react-spa');
8
9
 
9
10
  function initWorkspace(options = {}) {
10
11
  const targetDir = path.resolve(options.dir || process.cwd());
@@ -14,13 +15,16 @@ function initWorkspace(options = {}) {
14
15
  const install = Boolean(options.install);
15
16
  const profileName = options.profile || null;
16
17
  const appType = options.appType || null;
18
+ const runtime = normalizeRuntime(options.runtime || options.template);
19
+ const templateDir =
20
+ runtime === 'react-spa' ? REACT_SPA_TEMPLATE_DIR : LEGACY_TEMPLATE_DIR;
17
21
 
18
22
  if ((profileName && !appType) || (!profileName && appType)) {
19
23
  throw new Error('workspace init 绑定应用时必须同时提供 --profile 和 --app-type');
20
24
  }
21
25
 
22
- ensureCanInitialize(targetDir, force);
23
- copyTemplate(TEMPLATE_DIR, targetDir, {
26
+ ensureCanInitialize(targetDir, force, templateDir);
27
+ copyTemplate(templateDir, targetDir, {
24
28
  __WORKSPACE_PACKAGE_NAME__: packageName,
25
29
  });
26
30
 
@@ -69,7 +73,8 @@ function initWorkspace(options = {}) {
69
73
  return {
70
74
  targetDir,
71
75
  packageName,
72
- templateDir: TEMPLATE_DIR,
76
+ runtime,
77
+ templateDir,
73
78
  installedDependencies: install,
74
79
  bound,
75
80
  nextSteps: buildNextSteps(targetDir, install, bound),
@@ -78,13 +83,16 @@ function initWorkspace(options = {}) {
78
83
 
79
84
  function assertCanInitializeWorkspace(options = {}) {
80
85
  const targetDir = path.resolve(options.dir || process.cwd());
81
- ensureCanInitialize(targetDir, Boolean(options.force));
86
+ const runtime = normalizeRuntime(options.runtime || options.template);
87
+ const templateDir =
88
+ runtime === 'react-spa' ? REACT_SPA_TEMPLATE_DIR : LEGACY_TEMPLATE_DIR;
89
+ ensureCanInitialize(targetDir, Boolean(options.force), templateDir);
82
90
  return targetDir;
83
91
  }
84
92
 
85
- function ensureCanInitialize(targetDir, force) {
86
- if (!fs.existsSync(TEMPLATE_DIR)) {
87
- throw new Error(`workspace 模板不存在: ${TEMPLATE_DIR}`);
93
+ function ensureCanInitialize(targetDir, force, templateDir) {
94
+ if (!fs.existsSync(templateDir)) {
95
+ throw new Error(`workspace 模板不存在: ${templateDir}`);
88
96
  }
89
97
  if (!fs.existsSync(targetDir)) {
90
98
  fs.mkdirSync(targetDir, { recursive: true });
@@ -101,6 +109,10 @@ function ensureCanInitialize(targetDir, force) {
101
109
  }
102
110
  }
103
111
 
112
+ function normalizeRuntime(value) {
113
+ return value === 'react-spa' || value === 'spa' ? 'react-spa' : 'legacy';
114
+ }
115
+
104
116
  function copyTemplate(sourceDir, targetDir, replacements) {
105
117
  for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
106
118
  const sourcePath = path.join(sourceDir, entry.name);
@@ -7,7 +7,7 @@ description: Use OpenXiangda for ANY work inside a sy-lowcode-app-workspace or a
7
7
 
8
8
  OpenXiangda is a lightweight bridge between an AI coding tool and a private low-code platform. No AK/SK. The user provides a platform domain, logs in as a normal platform user, and the CLI calls `/openxiangda-api/v1` with that user's token.
9
9
 
10
- For any user-facing page (form pages, workflow form pages, custom code pages), OpenXiangda must use `sy-lowcode-app-workspace`: source files in `src/`, built into bundles, uploaded to OSS, and registered through `openxiangda workspace publish`. Do not rely on the platform's legacy default schema page.
10
+ OpenXiangda supports two workspace modes. Classic `sy-lowcode-app-workspace` publishes form/page bundles with `openxiangda workspace publish`. Phase 6 `react-spa` workspaces are standard React/Vite apps: publish resources with `openxiangda resource publish`, then publish the frontend dist with `openxiangda runtime deploy`. Do not send React SPA routes through old `isRenderNav`, app-shell, or workbench page parameters.
11
11
 
12
12
  ## TL;DR — Decision Card
13
13
 
@@ -17,7 +17,8 @@ For any user-facing page (form pages, workflow form pages, custom code pages), O
17
17
 
18
18
  | User says (zh / en) | Skill | First command |
19
19
  |---|---|---|
20
- | 发布 / 上线 / 部署 / publish / deploy / ship / release | `openxiangda-core` | `openxiangda workspace publish --profile <name> --changed --dry-run` |
20
+ | 发布 classic workspace / 上线旧模式 | `openxiangda-core` | `openxiangda workspace publish --profile <name> --changed --dry-run` |
21
+ | 发布 React SPA / Phase 6 runtime | `openxiangda-core` | `openxiangda resource publish --profile <name>` then `openxiangda runtime deploy --profile <name>` |
21
22
  | 只发布改动 / 增量 / 单页 / 单表 | `openxiangda-core` | `... --changed` / `--page <code>` / `--form <code>` / `--only pages/a,forms/b` |
22
23
  | 创建应用 / 新建 app / scaffold / 初始化工作区 | `openxiangda-app` | `openxiangda workspace init <dir> --profile <name> --app-name "..."` |
23
24
  | 绑定已有应用 / bind existing app | `openxiangda-app` | `openxiangda workspace bind --profile <name> --app-type APP_XXX` |
@@ -33,7 +34,7 @@ For any user-facing page (form pages, workflow form pages, custom code pages), O
33
34
 
34
35
  ### Hard rules — always
35
36
 
36
- - ✅ Publish through `openxiangda workspace publish --profile <name>` (with `--changed` / `--page` / `--form` / `--only` for routine edits).
37
+ - ✅ Publish classic workspaces through `openxiangda workspace publish --profile <name>`; publish React SPA workspaces through `openxiangda resource publish` + `openxiangda runtime deploy`.
37
38
  - ✅ User token lives in `~/.openxiangda/profiles.json`; project state in `.openxiangda/state.json` (IDs only).
38
39
  - ✅ Each profile (dev / prod / ...) has its own `appType` and resource IDs; never copy a `formUuid` / `pageId` / `workflowId` / `automationId` across profiles.
39
40
  - ✅ Run `openxiangda update check --json` at the start of substantial work; if `updateAvailable`, run `openxiangda update install` and `openxiangda skill install --force`.
@@ -40,6 +40,32 @@ cd ./my-app-workspace
40
40
  pnpm install
41
41
  ```
42
42
 
43
+ For new Phase 6 React SPA apps, use the standard React application template:
44
+
45
+ ```bash
46
+ openxiangda workspace init ./my-react-app \
47
+ --profile <name> \
48
+ --app-name "应用名称" \
49
+ --runtime react-spa
50
+ cd ./my-react-app
51
+ pnpm install
52
+ pnpm dev
53
+ ```
54
+
55
+ React SPA workspaces use React Router, Tailwind CSS, `OpenXiangdaProvider`
56
+ from `openxiangda/runtime/react`, and resources under `src/resources/`.
57
+ Do not use old `isRenderNav`, app-shell, or workbench page parameters inside
58
+ React SPA routes.
59
+
60
+ Publish React SPA apps with:
61
+
62
+ ```bash
63
+ openxiangda resource publish --profile <name>
64
+ openxiangda runtime deploy --profile <name>
65
+ ```
66
+
67
+ `runtime deploy` builds and activates the app-level SPA release for `/view/:appType/*`.
68
+
43
69
  Bind to an existing app only when the user explicitly provides `appType` or asks to reuse an existing platform app:
44
70
 
45
71
  ```bash
@@ -59,6 +85,7 @@ If the user explicitly asks to create an app without initializing a workspace:
59
85
 
60
86
  ```bash
61
87
  openxiangda app create "应用名称" --profile <name>
88
+ openxiangda app create "React应用名称" --profile <name> --runtime react-spa
62
89
  openxiangda workspace bind --profile <name> --app-type APP_XXX
63
90
  ```
64
91
 
@@ -71,6 +98,7 @@ openxiangda workspace bind --profile <name> --app-type APP_XXX
71
98
  - Store platform-specific IDs only in `.openxiangda/state.json`.
72
99
  - Use logical local codes for forms, pages, workflows, automations, and menus.
73
100
  - Before scaffolding a new business app, read `../../references/best-practices.md` and pick an architecture from the initialized `examples/best-practices/` catalog. Do not generate a large single-file app page when a template pattern already covers the scenario.
101
+ - For Phase 6 React SPA apps, use `--runtime react-spa`. The generated app owns routing/layouts/menus with React Router and Tailwind CSS; platform permissions still come from backend runtime bootstrap and route checks.
74
102
  - When publishing after app edits, prefer targeted commands (`workspace publish --changed --dry-run`, then `--changed`, `--page`, `--form`, or `--only`). Do not publish all forms/pages just because one page changed.
75
103
  - Use `openxiangda app snapshot <APP_XXX> --profile <name> --json` before changing an existing app.
76
104