openxiangda 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {
@@ -1,6 +1,119 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { getApiBaseUrl } from "./load-config.mjs";
3
+ import { getApiBaseUrl, rootDir } from "./load-config.mjs";
4
+
5
+ const PROJECT_STATE_FILE = ".openxiangda/state.json";
6
+
7
+ function readJson(filePath, fallback) {
8
+ try {
9
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
10
+ } catch {
11
+ return fallback;
12
+ }
13
+ }
14
+
15
+ function getWorkspaceRoot(config) {
16
+ return config.workspaceRoot || rootDir;
17
+ }
18
+
19
+ function getProjectStatePath(config) {
20
+ return path.join(getWorkspaceRoot(config), PROJECT_STATE_FILE);
21
+ }
22
+
23
+ function loadProjectState(config) {
24
+ return readJson(getProjectStatePath(config), {
25
+ version: 1,
26
+ profiles: {},
27
+ });
28
+ }
29
+
30
+ function saveProjectState(config, state) {
31
+ const statePath = getProjectStatePath(config);
32
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
33
+ const normalized = {
34
+ version: 1,
35
+ ...state,
36
+ profiles: state.profiles || {},
37
+ };
38
+ const tempPath = `${statePath}.${process.pid}.tmp`;
39
+ fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf-8");
40
+ fs.renameSync(tempPath, statePath);
41
+ }
42
+
43
+ function normalizeBaseUrl(value) {
44
+ return String(value || "").replace(/\/+$/, "");
45
+ }
46
+
47
+ function ensureResourceBuckets(bound) {
48
+ bound.resources = bound.resources || {};
49
+ bound.resources.forms = bound.resources.forms || {};
50
+ bound.resources.pages = bound.resources.pages || {};
51
+ bound.resources.workflows = bound.resources.workflows || {};
52
+ bound.resources.automations = bound.resources.automations || {};
53
+ bound.resources.menus = bound.resources.menus || {};
54
+ bound.resources.roles = bound.resources.roles || {};
55
+ bound.resources.pagePermissionGroups = bound.resources.pagePermissionGroups || {};
56
+ bound.resources.formPermissionGroups = bound.resources.formPermissionGroups || {};
57
+ }
58
+
59
+ function resolveProjectProfileName(config, state) {
60
+ if (config.openXiangdaProfile) return config.openXiangdaProfile;
61
+
62
+ const profiles = Object.entries(state.profiles || {});
63
+ const platformUrl = normalizeBaseUrl(config.platformUrl);
64
+ const matched = profiles.find(([, bound]) => {
65
+ if (bound?.appType !== config.appType) return false;
66
+ const boundBaseUrl = normalizeBaseUrl(bound?.baseUrl);
67
+ return !platformUrl || !boundBaseUrl || platformUrl === boundBaseUrl;
68
+ });
69
+ return matched?.[0] || "";
70
+ }
71
+
72
+ function readWorkspaceFormBinding(config, formName) {
73
+ if (!isOpenXiangdaMode(config)) return null;
74
+ const state = loadProjectState(config);
75
+ const profileName = resolveProjectProfileName(config, state);
76
+ if (!profileName) return null;
77
+ const bound = state.profiles?.[profileName];
78
+ const form = bound?.resources?.forms?.[formName];
79
+ if (!form?.formUuid) return null;
80
+ return {
81
+ profileName,
82
+ bound,
83
+ form,
84
+ };
85
+ }
86
+
87
+ function saveWorkspaceFormBinding(config, formName, formUuid, extra = {}) {
88
+ if (!isOpenXiangdaMode(config) || !formUuid) return false;
89
+
90
+ const state = loadProjectState(config);
91
+ const profileName =
92
+ resolveProjectProfileName(config, state) ||
93
+ config.openXiangdaProfile ||
94
+ config.appType ||
95
+ "default";
96
+
97
+ state.profiles = state.profiles || {};
98
+ state.profiles[profileName] = {
99
+ ...(state.profiles[profileName] || {}),
100
+ baseUrl: config.platformUrl,
101
+ appType: config.appType,
102
+ updatedAt: new Date().toISOString(),
103
+ };
104
+
105
+ const bound = state.profiles[profileName];
106
+ ensureResourceBuckets(bound);
107
+ bound.resources.forms[formName] = {
108
+ ...(bound.resources.forms[formName] || {}),
109
+ ...extra,
110
+ formUuid,
111
+ updatedAt: new Date().toISOString(),
112
+ };
113
+
114
+ saveProjectState(config, state);
115
+ return true;
116
+ }
4
117
 
5
118
  function readExportedStringConstant(filePath, exportName) {
6
119
  if (!filePath || !fs.existsSync(filePath)) return "";
@@ -520,42 +633,50 @@ export async function ensureSchemaFormUuid({
520
633
  }) {
521
634
  const meta = readSchemaMeta(schemaPath);
522
635
  const targetAppType = resolveTargetAppType(config, meta);
636
+ const workspaceBinding = readWorkspaceFormBinding(config, formName);
637
+ const effectiveMeta = workspaceBinding?.form?.formUuid
638
+ ? {
639
+ ...meta,
640
+ formUuid: workspaceBinding.form.formUuid,
641
+ appType: targetAppType,
642
+ }
643
+ : meta;
523
644
  const staleOpenXiangdaBinding = isStaleOpenXiangdaBinding(
524
645
  config,
525
- meta,
646
+ effectiveMeta,
526
647
  targetAppType,
527
648
  );
528
649
 
529
- if (meta.formUuid && !staleOpenXiangdaBinding) {
650
+ if (effectiveMeta.formUuid && !staleOpenXiangdaBinding) {
530
651
  let created = false;
531
652
  if (!dryRun) {
532
653
  const token = accessToken || (await getOpenApiAccessToken(config));
533
654
  const existingForm = await getOpenApiForm(config, token, {
534
655
  appType: targetAppType,
535
- formUuid: meta.formUuid,
656
+ formUuid: effectiveMeta.formUuid,
536
657
  });
537
658
 
538
659
  if (!existingForm) {
539
660
  await createOpenApiForm(config, token, {
540
- name: meta.title || formName,
661
+ name: effectiveMeta.title || formName,
541
662
  appType: targetAppType,
542
- formType: meta.formType || "receipt",
543
- relateUuid: meta.relateUuid,
544
- formUuid: meta.formUuid,
663
+ formType: effectiveMeta.formType || "receipt",
664
+ relateUuid: effectiveMeta.relateUuid,
665
+ formUuid: effectiveMeta.formUuid,
545
666
  builderVersion: config.defaults?.formBuilderVersion || "2.0",
546
667
  });
547
668
  created = true;
548
669
  console.log(
549
- ` ✓ ${formName}: 已按 schema formUuid 创建表单 ${meta.formUuid}`,
670
+ ` ✓ ${formName}: 已按已有 formUuid 创建表单 ${effectiveMeta.formUuid}`,
550
671
  );
551
672
  }
552
673
 
553
674
  if ((created || ensureMenu) && !isOpenXiangdaMode(config)) {
554
675
  const menuResult = await ensureOpenApiMenuForForm(config, token, {
555
676
  appType: targetAppType,
556
- formUuid: meta.formUuid,
557
- name: meta.title || formName,
558
- formType: meta.formType || "receipt",
677
+ formUuid: effectiveMeta.formUuid,
678
+ name: effectiveMeta.title || formName,
679
+ formType: effectiveMeta.formType || "receipt",
559
680
  parentId: config.menu?.parentId,
560
681
  icon: config.menu?.icon,
561
682
  });
@@ -566,17 +687,21 @@ export async function ensureSchemaFormUuid({
566
687
  );
567
688
  }
568
689
  }
690
+
691
+ saveWorkspaceFormBinding(config, formName, effectiveMeta.formUuid, {
692
+ title: effectiveMeta.title || formName,
693
+ });
569
694
  }
570
695
 
571
696
  return {
572
- ...meta,
697
+ ...effectiveMeta,
573
698
  appType: targetAppType,
574
699
  created,
575
700
  dryRunCreated: false,
576
701
  };
577
702
  }
578
703
 
579
- if (!meta.hasFormUuidProp) {
704
+ if (!meta.hasFormUuidProp && !isOpenXiangdaMode(config)) {
580
705
  throw new Error(
581
706
  `${formName}: schema.ts 中未找到 formUuid 字段,无法自动创建并回写`,
582
707
  );
@@ -608,7 +733,13 @@ export async function ensureSchemaFormUuid({
608
733
 
609
734
  const token = accessToken || (await getOpenApiAccessToken(config));
610
735
  const createdForm = await createOpenApiForm(config, token, createOptions);
611
- syncCreatedFormMeta(schemaPath, meta, targetAppType, createdForm.formUuid);
736
+ if (isOpenXiangdaMode(config)) {
737
+ saveWorkspaceFormBinding(config, formName, createdForm.formUuid, {
738
+ title: createOptions.name,
739
+ });
740
+ } else {
741
+ syncCreatedFormMeta(schemaPath, meta, targetAppType, createdForm.formUuid);
742
+ }
612
743
  console.log(` ✓ ${formName}: 已创建表单 ${createdForm.formUuid}`);
613
744
 
614
745
  let menuResult = { created: false, menu: null };
@@ -170,7 +170,77 @@ describe("form api provisioning helpers", () => {
170
170
  );
171
171
  });
172
172
 
173
- it("uses the bound OpenXiangda appType and recreates stale imported form ids", async () => {
173
+ it("uses OpenXiangda workspace state bindings before schema literals", async () => {
174
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "form-api-"));
175
+ tempDirs.push(dir);
176
+ const schemaPath = path.join(dir, "schema.ts");
177
+ fs.mkdirSync(path.join(dir, ".openxiangda"), { recursive: true });
178
+ fs.writeFileSync(
179
+ path.join(dir, ".openxiangda", "state.json"),
180
+ `${JSON.stringify(
181
+ {
182
+ version: 1,
183
+ profiles: {
184
+ dev: {
185
+ baseUrl: "https://platform.example.com/service",
186
+ appType: "APP_TEST",
187
+ resources: {
188
+ forms: {
189
+ customer: {
190
+ formUuid: "FORM_STATE",
191
+ title: "客户表单",
192
+ },
193
+ },
194
+ },
195
+ },
196
+ },
197
+ },
198
+ null,
199
+ 2,
200
+ )}\n`,
201
+ "utf-8",
202
+ );
203
+ fs.writeFileSync(
204
+ schemaPath,
205
+ [
206
+ 'const FORM_UUID = "";',
207
+ 'const APP_TYPE = process.env.OPENXIANGDA_APP_TYPE || process.env.APP_TYPE || "";',
208
+ 'export default { formMeta: { formUuid: FORM_UUID, appType: APP_TYPE, title: "客户表单", formType: "receipt" } };',
209
+ "",
210
+ ].join("\n"),
211
+ "utf-8",
212
+ );
213
+
214
+ const fetchMock = vi.fn(async (url: string) => {
215
+ if (String(url).endsWith("/forms")) {
216
+ return Response.json({
217
+ code: 200,
218
+ data: [{ formUuid: "FORM_STATE", appType: "APP_TEST" }],
219
+ });
220
+ }
221
+ throw new Error(`unexpected request: ${url}`);
222
+ });
223
+ vi.stubGlobal("fetch", fetchMock);
224
+
225
+ const result = await ensureSchemaFormUuid({
226
+ config: {
227
+ ...config,
228
+ workspaceRoot: dir,
229
+ openXiangdaAccessToken: "token",
230
+ openXiangdaProfile: "dev",
231
+ },
232
+ schemaPath,
233
+ formName: "customer",
234
+ accessToken: "token",
235
+ });
236
+
237
+ expect(result.formUuid).toBe("FORM_STATE");
238
+ expect(result.appType).toBe("APP_TEST");
239
+ expect(fetchMock).toHaveBeenCalledTimes(1);
240
+ expect(fs.readFileSync(schemaPath, "utf-8")).toContain('const FORM_UUID = "";');
241
+ });
242
+
243
+ it("uses the bound OpenXiangda appType and stores stale imported form ids in workspace state", async () => {
174
244
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "form-api-"));
175
245
  tempDirs.push(dir);
176
246
  const schemaPath = path.join(dir, "schema.ts");
@@ -223,7 +293,9 @@ describe("form api provisioning helpers", () => {
223
293
  const result = await ensureSchemaFormUuid({
224
294
  config: {
225
295
  ...config,
296
+ workspaceRoot: dir,
226
297
  openXiangdaAccessToken: "token",
298
+ openXiangdaProfile: "dev",
227
299
  },
228
300
  schemaPath,
229
301
  formName: "customer",
@@ -235,10 +307,60 @@ describe("form api provisioning helpers", () => {
235
307
  expect(result.appType).toBe("APP_TEST");
236
308
  expect(fetchMock).toHaveBeenCalledTimes(1);
237
309
  expect(fs.readFileSync(metaPath, "utf-8")).toContain(
238
- 'CUSTOMER_INFO_FORM_UUID = "FORM_NEW"',
310
+ 'CUSTOMER_INFO_FORM_UUID = "FORM_OLD"',
239
311
  );
240
312
  expect(fs.readFileSync(metaPath, "utf-8")).toContain(
241
- 'CUSTOMER_INFO_APP_TYPE = "APP_TEST"',
313
+ 'CUSTOMER_INFO_APP_TYPE = "APP_OLD"',
314
+ );
315
+ expect(
316
+ JSON.parse(
317
+ fs.readFileSync(path.join(dir, ".openxiangda", "state.json"), "utf-8"),
318
+ ).profiles.dev.resources.forms.customer.formUuid,
319
+ ).toBe("FORM_NEW");
320
+ });
321
+
322
+ it("creates OpenXiangda forms without requiring schema formUuid writeback", async () => {
323
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "form-api-"));
324
+ tempDirs.push(dir);
325
+ const schemaPath = path.join(dir, "schema.ts");
326
+ fs.writeFileSync(
327
+ schemaPath,
328
+ `export default { formMeta: { appType: "", title: "客户表单", formType: "receipt" } };\n`,
329
+ "utf-8",
242
330
  );
331
+
332
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
333
+ expect(String(url)).toBe(
334
+ "https://platform.example.com/service/openxiangda-api/v1/apps/APP_TEST/forms",
335
+ );
336
+ const body = JSON.parse(String(init?.body || "{}"));
337
+ expect(body.formUuid).toBeUndefined();
338
+ return Response.json({
339
+ code: 200,
340
+ data: { form: { formUuid: "FORM_CREATED" } },
341
+ });
342
+ });
343
+ vi.stubGlobal("fetch", fetchMock);
344
+
345
+ const result = await ensureSchemaFormUuid({
346
+ config: {
347
+ ...config,
348
+ workspaceRoot: dir,
349
+ openXiangdaAccessToken: "token",
350
+ openXiangdaProfile: "dev",
351
+ },
352
+ schemaPath,
353
+ formName: "customer",
354
+ accessToken: "token",
355
+ });
356
+
357
+ expect(result.created).toBe(true);
358
+ expect(result.formUuid).toBe("FORM_CREATED");
359
+ expect(fs.readFileSync(schemaPath, "utf-8")).not.toContain("FORM_CREATED");
360
+ expect(
361
+ JSON.parse(
362
+ fs.readFileSync(path.join(dir, ".openxiangda", "state.json"), "utf-8"),
363
+ ).profiles.dev.resources.forms.customer.formUuid,
364
+ ).toBe("FORM_CREATED");
243
365
  });
244
366
  });