openxiangda-cli 2.0.0-alpha.85 → 2.0.0-alpha.89

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 (65) hide show
  1. package/package.json +4 -4
  2. package/template/AGENTS.md +4 -4
  3. package/template/README.md +4 -4
  4. package/template/apps/server/package.json +1 -1
  5. package/template/apps/server/src/app.module.ts +14 -1
  6. package/template/apps/server/test/smoke.test.ts +3 -1
  7. package/template/apps/web/data-api-live.e2e.html +15 -0
  8. package/template/apps/web/e2e/data-api-live-fixture.tsx +141 -0
  9. package/template/apps/web/e2e/data-api-live.spec.ts +138 -0
  10. package/template/apps/web/e2e/field-protocol-fixture.tsx +280 -0
  11. package/template/apps/web/e2e/field-protocol.spec.ts +100 -0
  12. package/template/apps/web/e2e/resources.spec.ts +258 -20
  13. package/template/apps/web/field-protocol.e2e.html +12 -0
  14. package/template/apps/web/package.json +1 -1
  15. package/template/apps/web/playwright.config.ts +3 -1
  16. package/template/apps/web/scripts/check.mjs +8 -5
  17. package/template/apps/web/scripts/verify-build.mjs +1 -1
  18. package/template/apps/web/src/AuthoritativeSelector.tsx +165 -132
  19. package/template/apps/web/src/FilePreviewPage.tsx +30 -9
  20. package/template/apps/web/src/RoleSubjectSelect.tsx +128 -0
  21. package/template/apps/web/src/Shell.tsx +93 -6
  22. package/template/apps/web/src/components/platform-fields/AddressField.tsx +338 -0
  23. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +25 -6
  24. package/template/apps/web/src/components/platform-fields/CascadeField.tsx +49 -0
  25. package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +148 -0
  26. package/template/apps/web/src/components/platform-fields/JsonField.tsx +77 -0
  27. package/template/apps/web/src/components/platform-fields/LocationField.tsx +164 -0
  28. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +19 -44
  29. package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +67 -0
  30. package/template/apps/web/src/components/platform-fields/RichTextField.tsx +196 -0
  31. package/template/apps/web/src/components/platform-fields/SignatureField.tsx +315 -0
  32. package/template/apps/web/src/components/platform-fields/SubtableField.tsx +553 -0
  33. package/template/apps/web/src/components/platform-fields/address-value.ts +80 -0
  34. package/template/apps/web/src/components/platform-fields/cascade-value.ts +66 -0
  35. package/template/apps/web/src/components/platform-fields/directory-value.ts +53 -0
  36. package/template/apps/web/src/components/platform-fields/field-form-codec.ts +98 -0
  37. package/template/apps/web/src/components/platform-fields/location-value.ts +87 -0
  38. package/template/apps/web/src/components/platform-fields/resource-query.ts +131 -0
  39. package/template/apps/web/src/components/platform-fields/rich-text-value.ts +59 -0
  40. package/template/apps/web/src/components/platform-fields/subtable-value.ts +187 -0
  41. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +194 -108
  42. package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +8 -1
  43. package/template/apps/web/src/components/resource/SurfaceFields.tsx +385 -79
  44. package/template/apps/web/src/components/resource/generated-resource-definition.ts +18 -0
  45. package/template/apps/web/src/components/resource/resource-import.ts +4 -4
  46. package/template/apps/web/src/data-provider.ts +59 -34
  47. package/template/apps/web/src/platform-client.ts +341 -136
  48. package/template/apps/web/src/runtime.tsx +156 -17
  49. package/template/apps/web/src/styles.css +264 -0
  50. package/template/apps/web/test/address-field.test.ts +66 -0
  51. package/template/apps/web/test/cascade-value.test.ts +68 -0
  52. package/template/apps/web/test/contracts.test.ts +29 -5
  53. package/template/apps/web/test/directory-value.test.ts +55 -0
  54. package/template/apps/web/test/field-bounds.test.ts +17 -0
  55. package/template/apps/web/test/field-form-codec.test.ts +85 -0
  56. package/template/apps/web/test/location-field.test.ts +83 -0
  57. package/template/apps/web/test/resource-import.test.ts +8 -7
  58. package/template/apps/web/test/resource-query.test.ts +144 -0
  59. package/template/apps/web/test/rich-json-field.test.ts +53 -0
  60. package/template/apps/web/test/signature-serial-field.test.ts +40 -0
  61. package/template/apps/web/test/subtable-value.test.ts +144 -0
  62. package/template/openxiangda.config.ts +1 -1
  63. package/template/package.json +3 -3
  64. package/template/packages/contracts/src/generated.ts +95 -0
  65. package/template/scripts/verify-template-budget.mjs +11 -4
@@ -21,11 +21,132 @@ const definitions = resourceDefinitions as Record<string, DeclaredResource>;
21
21
  const allCapabilities = [...capabilities] as string[];
22
22
  const runtimeBase = `/view/${appCode}`;
23
23
 
24
+ const roleSubjects = [
25
+ {
26
+ subjectKey: 'membership:college-admin',
27
+ subjectKind: 'membership',
28
+ role: {
29
+ code: 'college_admin',
30
+ name: '学院管理员',
31
+ source: 'package',
32
+ description: '管理本学院业务数据',
33
+ },
34
+ revision: 1,
35
+ scopeDimensionCount: 1,
36
+ scopeSummary: [],
37
+ scopeSummaryTruncated: false,
38
+ validFrom: null,
39
+ validTo: null,
40
+ },
41
+ {
42
+ subjectKey: 'membership:instrument-admin',
43
+ subjectKind: 'membership',
44
+ role: {
45
+ code: 'instrument_admin',
46
+ name: '仪器管理员',
47
+ source: 'package',
48
+ description: '管理分配的仪器',
49
+ },
50
+ revision: 1,
51
+ scopeDimensionCount: 0,
52
+ scopeSummary: [],
53
+ scopeSummaryTruncated: false,
54
+ validFrom: null,
55
+ validTo: null,
56
+ },
57
+ ] as const;
58
+
59
+ function roleContext(subjectKey: string, authorized: boolean) {
60
+ const subject = roleSubjects.find(item => item.subjectKey === subjectKey)!;
61
+ const roleSessionId = `role-session-${subject.role.code}`;
62
+ return {
63
+ schemaVersion: 'openxiangda.role-session-context/v2',
64
+ state: 'active',
65
+ environment: {
66
+ id: 'preproduction-id',
67
+ key: 'preproduction',
68
+ kind: 'preproduction',
69
+ activeAppVersionId: 'app-version-1',
70
+ headRevision: 1,
71
+ authzVersion: 1,
72
+ roleSubjectSetVersion: 'subjects-1',
73
+ },
74
+ identityScope: `preproduction-id:${subjectKey}:1`,
75
+ subjectProfile: {
76
+ schemaVersion: 'openxiangda.subject-profile/v2',
77
+ userId: 'acceptance-user',
78
+ displayName: '验收用户',
79
+ avatarUrl: null,
80
+ jobNumber: null,
81
+ affiliatedDepartment: { id: 'department-1', name: '实验中心' },
82
+ },
83
+ currentRoleSubject: subject,
84
+ roleSubjectPage: {
85
+ schemaVersion: 'openxiangda.role-subject-page/v2',
86
+ items: roleSubjects,
87
+ total: roleSubjects.length,
88
+ nextCursor: null,
89
+ roleSubjectSetVersion: 'subjects-1',
90
+ },
91
+ roleSession: {
92
+ schemaVersion: 'openxiangda.native-role-session/v2',
93
+ id: roleSessionId,
94
+ environmentId: 'preproduction-id',
95
+ environmentKey: 'preproduction',
96
+ loginSessionId: 'login-session-1',
97
+ userId: 'acceptance-user',
98
+ selectionKey: subjectKey,
99
+ subjectKind: 'membership',
100
+ activeRoleCode: subject.role.code,
101
+ activeRoleSource: 'package',
102
+ activeRoleMembershipId: subjectKey.slice('membership:'.length),
103
+ activeAuthzRevisionId: 'authz-1',
104
+ activeAuthzVersion: 1,
105
+ activeAppVersionId: 'app-version-1',
106
+ activeEnvironmentHeadRevision: 1,
107
+ activeMembershipRevision: 1,
108
+ status: 'active',
109
+ issuedAt: '2026-08-23T00:00:00.000Z',
110
+ expiresAt: '2099-08-23T00:00:00.000Z',
111
+ lastUsedAt: '2026-08-23T00:00:00.000Z',
112
+ },
113
+ principal: {
114
+ schemaVersion: 'openxiangda.native-principal/v2',
115
+ tenantId: 'tenant-1',
116
+ appCode,
117
+ environmentId: 'preproduction-id',
118
+ environmentKey: 'preproduction',
119
+ activeAppVersionId: 'app-version-1',
120
+ environmentHeadRevision: 1,
121
+ principalType: 'user',
122
+ subjectId: 'acceptance-user',
123
+ userId: 'acceptance-user',
124
+ loginSessionId: 'login-session-1',
125
+ roleSessionId,
126
+ subjectKind: 'membership',
127
+ activeRoleCode: subject.role.code,
128
+ activeRoleSource: 'package',
129
+ activeRoleMembershipId: subjectKey.slice('membership:'.length),
130
+ activeMembershipRevision: 1,
131
+ authzRevisionId: 'authz-1',
132
+ authzVersion: 1,
133
+ scopeDataVersion: 'scope-1',
134
+ isAppSuperAdmin: false,
135
+ capabilities: authorized ? allCapabilities : [],
136
+ expiresAt: null,
137
+ },
138
+ };
139
+ }
140
+
24
141
  async function mockPlatform(
25
142
  page: Page,
26
143
  authorized = true,
27
144
  targetRuntimeBase = runtimeBase,
28
145
  ) {
146
+ let activeSubjectKey = roleSubjects[0].subjectKey;
147
+ let contextReads = 0;
148
+ const observed: Array<{ path: string; roleSessionId: string }> = [];
149
+ const switches: Array<Record<string, unknown>> = [];
29
150
  await page.route(`**${targetRuntimeBase}/**`, async route => {
30
151
  if (route.request().resourceType() !== 'document') return route.continue();
31
152
  const response = await route.fetch();
@@ -39,36 +160,69 @@ async function mockPlatform(
39
160
  });
40
161
  await page.route('**/service/**', async route => {
41
162
  const url = new URL(route.request().url());
42
- if (url.pathname.endsWith('/native/current-user')) {
163
+ const request = route.request();
164
+ const roleSessionId = request.headers()['x-openxiangda-role-session-id'] || '';
165
+ if (url.pathname.endsWith('/native/authz/role-session/context/role-subjects')) {
166
+ return route.fulfill({
167
+ contentType: 'application/json',
168
+ body: JSON.stringify({
169
+ code: 200,
170
+ data: {
171
+ schemaVersion: 'openxiangda.role-subject-page/v2',
172
+ items: roleSubjects,
173
+ total: roleSubjects.length,
174
+ nextCursor: null,
175
+ roleSubjectSetVersion: 'subjects-1',
176
+ },
177
+ }),
178
+ });
179
+ }
180
+ if (url.pathname.endsWith('/native/authz/role-session/context/switch')) {
181
+ const body = request.postDataJSON() as Record<string, unknown>;
182
+ switches.push(body);
183
+ if (roleSubjects.some(item => item.subjectKey === body.targetRoleSubjectKey)) {
184
+ activeSubjectKey = String(body.targetRoleSubjectKey) as typeof activeSubjectKey;
185
+ }
186
+ return route.fulfill({
187
+ contentType: 'application/json',
188
+ body: JSON.stringify({
189
+ code: 200,
190
+ data: roleContext(activeSubjectKey, authorized),
191
+ }),
192
+ });
193
+ }
194
+ if (url.pathname.endsWith('/native/authz/role-session/context')) {
195
+ contextReads += 1;
196
+ return route.fulfill({
197
+ contentType: 'application/json',
198
+ body: JSON.stringify({
199
+ code: 200,
200
+ data: roleContext(activeSubjectKey, authorized),
201
+ }),
202
+ });
203
+ }
204
+ const sourceQuery = url.pathname.match(
205
+ /\/native\/data-resources\/([^/]+)\/fields\/([^/]+)\/source\/query$/
206
+ );
207
+ if (sourceQuery) {
208
+ observed.push({ path: url.pathname, roleSessionId });
43
209
  return route.fulfill({
44
210
  contentType: 'application/json',
45
211
  body: JSON.stringify({
46
212
  code: 200,
47
213
  data: {
48
- schemaVersion: 'openxiangda.current-user/v2',
49
- appCode,
50
- environment: {
51
- id: 'preproduction-id',
52
- key: 'preproduction',
53
- activeAppVersionId: 'app-version-1',
54
- headRevision: 1,
55
- authzRevisionId: 'authz-1',
56
- authzVersion: 1,
57
- scopeDataVersion: 'scope-1',
58
- },
59
- principal: {
60
- type: 'user',
61
- userId: 'acceptance-user',
62
- roleCodes: [],
63
- capabilityCodes: authorized ? allCapabilities : [],
64
- isAppSuperAdmin: authorized,
65
- },
214
+ schemaVersion: 'openxiangda.data-field-source-page/v2',
215
+ resourceCode: decodeURIComponent(sourceQuery[1]),
216
+ fieldCode: decodeURIComponent(sourceQuery[2]),
217
+ items: [],
218
+ nextCursor: null,
66
219
  },
67
220
  }),
68
221
  });
69
222
  }
70
223
  const query = url.pathname.match(/\/native\/data\/([^/]+)\/query$/);
71
224
  if (query) {
225
+ observed.push({ path: url.pathname, roleSessionId });
72
226
  return route.fulfill({
73
227
  contentType: 'application/json',
74
228
  body: JSON.stringify({
@@ -86,18 +240,33 @@ async function mockPlatform(
86
240
  }),
87
241
  });
88
242
  }
89
- if (url.pathname.includes('/native/directory/')) {
243
+ if (url.pathname.includes('/directory/')) {
244
+ observed.push({ path: url.pathname, roleSessionId });
90
245
  return route.fulfill({
91
246
  contentType: 'application/json',
92
247
  body: JSON.stringify({ code: 200, data: { items: [], nextCursor: null } }),
93
248
  });
94
249
  }
250
+ if (url.pathname.includes('/openxiangda-app-api/v2/')) {
251
+ observed.push({ path: url.pathname, roleSessionId });
252
+ return route.fulfill({
253
+ contentType: 'application/json',
254
+ body: JSON.stringify({ code: 200, data: { status: 'ok' } }),
255
+ });
256
+ }
95
257
  return route.fulfill({
96
258
  status: 404,
97
259
  contentType: 'application/json',
98
260
  body: JSON.stringify({ code: 404, message: 'not mocked' }),
99
261
  });
100
262
  });
263
+ return {
264
+ observed,
265
+ switches,
266
+ get contextReads() {
267
+ return contextReads;
268
+ },
269
+ };
101
270
  }
102
271
 
103
272
  test('renders the compiled desktop resource contract and shared workbench', async ({ page }) => {
@@ -145,6 +314,75 @@ test('opens the stable admin entry under the preproduction gateway mount', async
145
314
  await expect(page).toHaveURL(`${devRuntimeBase}/${codes[0]}`);
146
315
  });
147
316
 
317
+ test('binds Data, Directory, App API, and role switching to one active RoleSession', async ({ page }) => {
318
+ const platform = await mockPlatform(page);
319
+ await page.goto(`${runtimeBase}/admin`);
320
+ if (codes.length) {
321
+ await expect(page.locator('.oxa-list-surface')).toBeVisible();
322
+ } else {
323
+ await expect(page.getByText('还没有声明数据资源')).toBeVisible();
324
+ }
325
+
326
+ await page.evaluate(async () => {
327
+ const client = await import('/src/platform-client.ts');
328
+ await client.searchResource('acceptance-resource', 'name', {
329
+ keyword: '显微镜',
330
+ });
331
+ await client.searchDirectory('user', { keyword: '老师' });
332
+ await client.requestApplicationApi('/health/current-user');
333
+ });
334
+
335
+ const initialSession = 'role-session-college_admin';
336
+ expect(
337
+ platform.observed.filter(item =>
338
+ /\/native\/data(?:-resources)?\/|\/directory\/|\/openxiangda-app-api\/v2\//.test(
339
+ item.path
340
+ )
341
+ )
342
+ ).toEqual(
343
+ expect.arrayContaining([
344
+ expect.objectContaining({ roleSessionId: initialSession }),
345
+ expect.objectContaining({ roleSessionId: initialSession }),
346
+ expect.objectContaining({ roleSessionId: initialSession }),
347
+ ])
348
+ );
349
+
350
+ await page.locator('.oxa-current-user').click();
351
+ await page.getByText('切换应用角色', { exact: true }).click();
352
+ await page.getByRole('combobox', { name: '选择应用角色' }).click();
353
+ await page.getByText('仪器管理员', { exact: true }).click();
354
+ await page.getByRole('button', { name: '切换角色' }).click();
355
+
356
+ await expect.poll(() => platform.switches.length).toBe(1);
357
+ expect(platform.switches[0]).toMatchObject({
358
+ targetRoleSubjectKey: 'membership:instrument-admin',
359
+ expectedRoleSessionId: initialSession,
360
+ environmentKey: 'preproduction',
361
+ });
362
+ await expect.poll(() => platform.contextReads).toBeGreaterThan(1);
363
+ await page.locator('.oxa-current-user').click();
364
+ await expect(page.locator('.oxa-user-dropdown-roles')).toContainText(
365
+ '仪器管理员'
366
+ );
367
+ await page.keyboard.press('Escape');
368
+ await page.evaluate(async () => {
369
+ const client = await import('/src/platform-client.ts');
370
+ await client.searchResource('acceptance-resource', 'name', {
371
+ keyword: '光谱仪',
372
+ });
373
+ });
374
+ await expect
375
+ .poll(
376
+ () =>
377
+ platform.observed.filter(
378
+ item =>
379
+ item.path.includes('/native/data-resources/') &&
380
+ item.roleSessionId === 'role-session-instrument_admin'
381
+ ).length
382
+ )
383
+ .toBeGreaterThan(0);
384
+ });
385
+
148
386
  test('renders platform mobile controls from declared field widgets', async ({ page }) => {
149
387
  const candidate = codes
150
388
  .map(code => ({ code, definition: definitions[code] }))
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Field protocol acceptance</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/e2e/field-protocol-fixture.tsx"></script>
11
+ </body>
12
+ </html>
@@ -16,7 +16,7 @@
16
16
  "antd": "6.4.3",
17
17
  "dayjs": "1.11.18",
18
18
  "docx-preview": "0.3.7",
19
- "openxiangda-contracts": "2.0.0-alpha.39",
19
+ "openxiangda-contracts": "2.0.0-alpha.43",
20
20
  "react": "19.2.8",
21
21
  "react-dom": "19.2.8",
22
22
  "react-router-dom": "7.8.2",
@@ -16,7 +16,9 @@ export default defineConfig({
16
16
  env: {
17
17
  OPENXIANGDA_WEB_PORT: String(e2ePort),
18
18
  OPENXIANGDA_ENVIRONMENT_KEY: 'production',
19
- OPENXIANGDA_DEV_PROXY: 'http://127.0.0.1:7001',
19
+ OPENXIANGDA_DEV_PROXY:
20
+ process.env.OPENXIANGDA_E2E_PLATFORM_URL ||
21
+ 'http://127.0.0.1:7001',
20
22
  },
21
23
  },
22
24
  });
@@ -14,10 +14,11 @@ visit(join(root, 'src'));
14
14
  const lines = files.reduce((total, file) => total + readFileSync(file, 'utf8').split(/\r?\n/).length, 0);
15
15
  // The shared Surface renderer is deliberately counted as application source;
16
16
  // keep its small budget explicit instead of hiding it in generated output.
17
- if (files.length > 20) throw new Error(`WEB_SOURCE_FILE_BUDGET_EXCEEDED:${files.length}>20`);
18
- // Mobile shells, Surface filters, generated resource CRUD, atomic batch actions,
19
- // and import preview are shared application infrastructure.
20
- if (lines > 6_000) throw new Error(`WEB_BUSINESS_LOC_BUDGET_EXCEEDED:${lines}>6000`);
17
+ // Field Kit families are separate modules so a semantic field does not add a
18
+ // branch to one universal renderer. Keep explicit ceilings while allowing the
19
+ // complete 30-field standard kit to remain decomposed.
20
+ if (files.length > 36) throw new Error(`WEB_SOURCE_FILE_BUDGET_EXCEEDED:${files.length}>36`);
21
+ if (lines > 10_000) throw new Error(`WEB_BUSINESS_LOC_BUDGET_EXCEEDED:${lines}>10000`);
21
22
  const source = files.map((file) => readFileSync(file, 'utf8')).join('\n');
22
23
  for (const marker of [
23
24
  '@umijs/',
@@ -26,7 +27,9 @@ for (const marker of [
26
27
  'instrument_query',
27
28
  'instrument_save',
28
29
  'function.invoke',
29
- 'role' + '-session',
30
+ 'authorization' + '-mode',
31
+ 'focused' + '_role',
32
+ '/native/current' + '-user',
30
33
  'college-' + 'am',
31
34
  'user-' + 'wang',
32
35
  'dept-' + 'am-test',
@@ -13,7 +13,7 @@ const gzip = javascript.reduce(
13
13
  (bytes, file) => bytes + gzipSync(readFileSync(file), { level: 9 }).byteLength,
14
14
  0
15
15
  );
16
- if (gzip > 750_000) throw new Error(`WEB_JS_GZIP_BUDGET_EXCEEDED:${gzip}>750000`);
16
+ if (gzip > 800_000) throw new Error(`WEB_JS_GZIP_BUDGET_EXCEEDED:${gzip}>800000`);
17
17
  if (names.some(name => name.endsWith('.map'))) throw new Error('WEB_SOURCE_MAP_FORBIDDEN');
18
18
  const html = readFileSync(join(dist, 'index.html'), 'utf8');
19
19
  if (!html.includes('<div id="root"></div>')) throw new Error('WEB_ROOT_MISSING');