openxiangda-cli 2.0.0-alpha.57 → 2.0.0-alpha.59

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 (39) hide show
  1. package/dist/commands/create.d.ts.map +1 -1
  2. package/dist/commands/create.js +13 -5
  3. package/dist/commands/create.js.map +1 -1
  4. package/dist/create-workspace.d.ts +6 -1
  5. package/dist/create-workspace.d.ts.map +1 -1
  6. package/dist/create-workspace.js +12 -9
  7. package/dist/create-workspace.js.map +1 -1
  8. package/package.json +4 -4
  9. package/template/README.md +3 -1
  10. package/template/apps/server/package.json +1 -1
  11. package/template/apps/web/e2e/instruments.spec.ts +1197 -9
  12. package/template/apps/web/package.json +4 -2
  13. package/template/apps/web/scripts/check.mjs +2 -2
  14. package/template/apps/web/scripts/verify-build.mjs +14 -2
  15. package/template/apps/web/src/AuthoritativeSelector.tsx +371 -0
  16. package/template/apps/web/src/CollegePage.tsx +181 -0
  17. package/template/apps/web/src/FilePreviewPage.tsx +303 -0
  18. package/template/apps/web/src/InstrumentDetailPage.tsx +94 -20
  19. package/template/apps/web/src/InstrumentForm.tsx +347 -94
  20. package/template/apps/web/src/InstrumentFormPage.tsx +49 -9
  21. package/template/apps/web/src/InstrumentListPage.tsx +30 -35
  22. package/template/apps/web/src/Shell.tsx +229 -41
  23. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +226 -0
  24. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +698 -0
  25. package/template/apps/web/src/data-provider.ts +1 -1
  26. package/template/apps/web/src/fields.ts +17 -6
  27. package/template/apps/web/src/instrument.ts +1 -1
  28. package/template/apps/web/src/main.tsx +21 -1
  29. package/template/apps/web/src/platform-client.ts +248 -2
  30. package/template/apps/web/src/runtime-meta.ts +15 -0
  31. package/template/apps/web/src/runtime.tsx +4 -2
  32. package/template/apps/web/src/styles.css +1007 -28
  33. package/template/apps/web/test/contracts.test.ts +473 -10
  34. package/template/openxiangda.config.ts +24 -5
  35. package/template/package.json +3 -3
  36. package/template/packages/contracts/src/generated.ts +22 -0
  37. package/template/platform/data/colleges.ts +21 -0
  38. package/template/platform/data/instruments.ts +1 -1
  39. package/template/scripts/verify-template-budget.mjs +2 -2
@@ -1,4 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
2
3
  import test from 'node:test';
3
4
  import {
4
5
  instrumentFieldAllowed,
@@ -6,16 +7,28 @@ import {
6
7
  } from '../src/InstrumentForm';
7
8
  import { instrumentFields } from '../src/fields';
8
9
  import {
10
+ browseDepartmentTree,
11
+ browseDepartmentUsers,
9
12
  instrumentDataApi,
13
+ fetchDataFileBlob,
14
+ loadDataFilePreview,
10
15
  loadCurrentIdentity,
16
+ resolveCollegeScope,
17
+ resolveDirectory,
18
+ searchCollegeScope,
19
+ searchDirectory,
11
20
  } from '../src/platform-client';
12
21
  import {
13
22
  applicationApiPath,
14
23
  readRuntimeMount,
24
+ resolveAttachmentPreviewPath,
15
25
  resolveApplicationApiPath,
16
26
  resolveApplicationBasename,
17
27
  } from '../src/runtime-meta';
18
28
 
29
+ const collegeId = '11111111-1111-4111-8111-111111111111';
30
+ const userId = '22222222-2222-4222-8222-222222222222';
31
+
19
32
  test('the golden module exposes exactly 30 fields and five restricted fields', () => {
20
33
  assert.equal(instrumentFields.length, 30);
21
34
  assert.deepEqual(
@@ -36,6 +49,102 @@ test('the golden module exposes exactly 30 fields and five restricted fields', (
36
49
  );
37
50
  });
38
51
 
52
+ test('application shell and instrument surfaces match the approved navigation model', () => {
53
+ const shell = readFileSync(new URL('../src/Shell.tsx', import.meta.url), 'utf8');
54
+ const list = readFileSync(
55
+ new URL('../src/InstrumentListPage.tsx', import.meta.url),
56
+ 'utf8'
57
+ );
58
+ const routes = readFileSync(new URL('../src/main.tsx', import.meta.url), 'utf8');
59
+ const form = readFileSync(
60
+ new URL('../src/InstrumentForm.tsx', import.meta.url),
61
+ 'utf8'
62
+ );
63
+ const colleges = readFileSync(
64
+ new URL('../src/CollegePage.tsx', import.meta.url),
65
+ 'utf8'
66
+ );
67
+ const directory = readFileSync(
68
+ new URL('../src/components/platform-fields/PlatformDirectoryPicker.tsx', import.meta.url),
69
+ 'utf8'
70
+ );
71
+ const attachment = readFileSync(
72
+ new URL('../src/components/platform-fields/AttachmentFileList.tsx', import.meta.url),
73
+ 'utf8'
74
+ );
75
+ const preview = readFileSync(
76
+ new URL('../src/FilePreviewPage.tsx', import.meta.url),
77
+ 'utf8'
78
+ );
79
+ for (const marker of [
80
+ '仪器资源管理',
81
+ '工作台',
82
+ '仪器管理',
83
+ '仪器资源',
84
+ '开放记录',
85
+ '基础数据',
86
+ '学院字典',
87
+ '系统设置',
88
+ '应用管理员',
89
+ '收起侧边栏',
90
+ ]) {
91
+ assert.match(shell, new RegExp(marker));
92
+ }
93
+ assert.doesNotMatch(shell, /<span>\{identity\.userId\}<\/span>/);
94
+ assert.doesNotMatch(shell, /验证本地 Nest|roleCodes\.join|environment\.key/);
95
+ assert.doesNotMatch(list, /<Drawer|surface="drawer"/);
96
+ assert.match(routes, /path="\/instruments\/new"[\s\S]*<InstrumentFormPage mode="create"/);
97
+ assert.match(colleges, /<Drawer/);
98
+ assert.match(colleges, /size=\{520\}/);
99
+ assert.match(directory, /width=\{kind === 'user' \? 980 : 760\}/);
100
+ assert.match(directory, /browseDepartmentTree|browseDepartmentUsers/);
101
+ assert.match(directory, /选择成员|选择部门|组织架构|已选/);
102
+ assert.match(form, /oxa-form-full/);
103
+ assert.match(form, /PlatformDirectoryPicker/);
104
+ assert.match(form, /oxa-file-dropzone/);
105
+ assert.match(form, /oxa-detail-layout/);
106
+ assert.match(routes, /path="\/files\/:resourceCode\/:fileId\/preview"/);
107
+ assert.match(attachment, /Image\.PreviewGroup/);
108
+ assert.match(attachment, /attachmentPreviewPath/);
109
+ assert.match(preview, /import\('docx-preview'\)/);
110
+ assert.match(preview, /import\('xlsx'\)/);
111
+ assert.match(preview, /只读预览/);
112
+ assert.doesNotMatch(form, /instrumentFileContentUrl|window\.open/);
113
+ assert.doesNotMatch(
114
+ `${form}\n${directory}`,
115
+ /FormContext|tools\/openxiangda|RoleSession/
116
+ );
117
+ });
118
+
119
+ test('college, user, and department selectors have authoritative owners only', () => {
120
+ const appConfig = readFileSync(
121
+ new URL('../../../openxiangda.config.ts', import.meta.url),
122
+ 'utf8'
123
+ );
124
+ const colleges = readFileSync(
125
+ new URL('../../../platform/data/colleges.ts', import.meta.url),
126
+ 'utf8'
127
+ );
128
+ const fields = readFileSync(new URL('../src/fields.ts', import.meta.url), 'utf8');
129
+ assert.match(appConfig, /valueType:\s*'uuid'/);
130
+ assert.match(appConfig, /kind:\s*'native_resource'/);
131
+ assert.match(appConfig, /resourceCode:\s*'colleges'/);
132
+ assert.match(appConfig, /data:\s*\{\s*resources:\s*\[colleges, instruments\]/);
133
+ assert.match(colleges, /code:\s*'name'.*type:\s*'string'/s);
134
+ assert.match(colleges, /code:\s*'enabled'.*type:\s*'boolean'/s);
135
+ assert.match(fields, /kind:\s*'scope'/);
136
+ assert.match(fields, /kind:\s*'directory-user'/);
137
+ assert.match(fields, /kind:\s*'directory-department'/);
138
+ for (const marker of [
139
+ ['college', 'am'].join('-'),
140
+ ['user', 'wang'].join('-'),
141
+ ['dept', 'am-test'].join('-'),
142
+ ]) {
143
+ assert.equal(appConfig.includes(marker), false);
144
+ assert.equal(fields.includes(marker), false);
145
+ }
146
+ });
147
+
39
148
  test('college create restores five fields while update omits college reassignment', () => {
40
149
  const protectedFields = instrumentFields.filter(
41
150
  field => field.createCapability && field.updateCapability
@@ -53,8 +162,8 @@ test('college create restores five fields while update omits college reassignmen
53
162
  chineseName: '电子显微镜',
54
163
  assetCode: 'ASSET-001',
55
164
  assetValue: 100,
56
- collegeId: 'college-am',
57
- instrumentAdminIds: ['user-wang'],
165
+ collegeId,
166
+ instrumentAdminIds: [userId],
58
167
  revision: 7,
59
168
  };
60
169
  assert.equal(
@@ -78,15 +187,15 @@ test('college create restores five fields while update omits college reassignmen
78
187
  chineseName: '电子显微镜',
79
188
  assetCode: 'ASSET-001',
80
189
  assetValue: 100,
81
- collegeId: 'college-am',
82
- instrumentAdminIds: ['user-wang'],
190
+ collegeId,
191
+ instrumentAdminIds: [userId],
83
192
  });
84
193
  assert.deepEqual(sanitizeInstrumentValues(values, 'edit', hasCapability), {
85
194
  instrumentCode: 'INS-001',
86
195
  chineseName: '电子显微镜',
87
196
  assetCode: 'ASSET-001',
88
197
  assetValue: 100,
89
- instrumentAdminIds: ['user-wang'],
198
+ instrumentAdminIds: [userId],
90
199
  });
91
200
  });
92
201
 
@@ -162,6 +271,92 @@ test('deployed app/environment come only from platform-injected runtime meta', (
162
271
  resolveApplicationApiPath(mount, 'api/instruments/context'),
163
272
  '/service/openxiangda-app-api/v2/instrument-center/production/api/instruments/context'
164
273
  );
274
+ assert.equal(
275
+ resolveAttachmentPreviewPath(
276
+ mount,
277
+ 'instruments',
278
+ '77777777-7777-4777-8777-777777777777'
279
+ ),
280
+ '/view/instrument-center/files/instruments/77777777-7777-4777-8777-777777777777/preview'
281
+ );
282
+ });
283
+
284
+ test('managed file preview reads metadata and bytes without a RoleSession', async () => {
285
+ const calls: Array<{ path: string; init?: RequestInit }> = [];
286
+ const originalFetch = globalThis.fetch;
287
+ globalThis.fetch = async (input, init) => {
288
+ const path = String(input);
289
+ calls.push({ path, init });
290
+ if (path.endsWith('/dev-sessions/current')) {
291
+ return Response.json({
292
+ code: 200,
293
+ data: {
294
+ environment: { id: 'test-id', key: 'preproduction' },
295
+ principal: {
296
+ userId,
297
+ roleCodes: ['school_admin'],
298
+ capabilityCodes: [],
299
+ isAppSuperAdmin: false,
300
+ },
301
+ },
302
+ });
303
+ }
304
+ if (path.includes('/files/file-1/preview?')) {
305
+ return Response.json({
306
+ code: 200,
307
+ data: {
308
+ schemaVersion: 'openxiangda.data-file-preview/v2',
309
+ resourceCode: 'instruments',
310
+ file: {
311
+ schemaVersion: 'openxiangda.data-file-ref/v2',
312
+ id: 'file-1',
313
+ name: 'manual.docx',
314
+ size: 12,
315
+ contentType:
316
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
317
+ },
318
+ extension: 'docx',
319
+ previewType: 'office',
320
+ renderMode: 'docx-html',
321
+ previewSurface: 'document',
322
+ previewProvider: 'browser',
323
+ canPreview: true,
324
+ canDownload: true,
325
+ },
326
+ });
327
+ }
328
+ if (path.includes('/files/file-1/content?')) {
329
+ return new Response(new Uint8Array([80, 75, 3, 4]), {
330
+ status: 200,
331
+ headers: {
332
+ 'content-type':
333
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
334
+ },
335
+ });
336
+ }
337
+ return Response.json({ code: 404, message: 'not mocked' }, { status: 404 });
338
+ };
339
+ try {
340
+ await loadCurrentIdentity();
341
+ const preview = await loadDataFilePreview('instruments', 'file-1');
342
+ const blob = await fetchDataFileBlob('instruments', 'file-1');
343
+ assert.equal(preview.renderMode, 'docx-html');
344
+ assert.equal(blob.size, 4);
345
+ } finally {
346
+ globalThis.fetch = originalFetch;
347
+ }
348
+ const previewCall = calls.find(call => call.path.includes('/files/file-1/preview?'))!;
349
+ const contentCall = calls.find(call => call.path.includes('/files/file-1/content?'))!;
350
+ assert.match(previewCall.path, /environmentKey=preproduction/);
351
+ assert.match(contentCall.path, /disposition=inline/);
352
+ assert.equal(
353
+ calls.some(call =>
354
+ Object.keys(call.init?.headers || {}).some(header =>
355
+ header.toLowerCase().includes('role-session')
356
+ )
357
+ ),
358
+ false
359
+ );
165
360
  });
166
361
 
167
362
  test('list uses the native Data API with server paging, filters, and sorting', async () => {
@@ -206,29 +401,297 @@ test('list uses the native Data API with server paging, filters, and sorting', a
206
401
  await instrumentDataApi.list({
207
402
  page: 2,
208
403
  pageSize: 20,
209
- collegeId: 'college-am',
210
- instrumentAdminId: 'user-wang',
404
+ collegeId,
405
+ instrumentAdminId: userId,
211
406
  sort: { field: 'assetValue', order: 'asc' },
212
407
  });
408
+ await instrumentDataApi.list({ page: 1, pageSize: 20 });
213
409
  } finally {
214
410
  globalThis.fetch = originalFetch;
215
411
  }
216
- assert.equal(calls.length, 2);
412
+ assert.equal(calls.length, 3);
217
413
  assert.match(
218
414
  calls[1]!.path,
219
415
  /\/native\/data\/instruments\/query$/
220
416
  );
221
417
  const body = JSON.parse(String(calls[1]!.init?.body));
418
+ const resourceSource = readFileSync(
419
+ new URL('../../../platform/data/instruments.ts', import.meta.url),
420
+ 'utf8'
421
+ );
422
+ const declaredFields = new Set(
423
+ [...resourceSource.matchAll(/\{\s*code:\s*'([^']+)'/g)].map(
424
+ match => match[1]!
425
+ )
426
+ );
427
+ assert.deepEqual(
428
+ [...declaredFields],
429
+ instrumentFields.filter(field => !field.system).map(field => field.key)
430
+ );
222
431
  assert.equal(body.environmentKey, 'preproduction');
223
432
  assert.equal(body.limit, 20);
224
433
  assert.equal(body.offset, 20);
225
434
  assert.deepEqual(body.order, [{ field: 'assetValue', direction: 'asc' }]);
435
+ assert.equal(
436
+ [...body.filters, ...body.order].every(item =>
437
+ declaredFields.has(item.field)
438
+ ),
439
+ true
440
+ );
226
441
  assert.deepEqual(body.filters, [
227
- { field: 'collegeId', operator: 'eq', value: 'college-am' },
442
+ { field: 'collegeId', operator: 'eq', value: collegeId },
228
443
  {
229
444
  field: 'instrumentAdminIds',
230
445
  operator: 'cs',
231
- value: ['user-wang'],
446
+ value: [userId],
232
447
  },
233
448
  ]);
449
+ const defaultBody = JSON.parse(String(calls[2]!.init?.body));
450
+ assert.deepEqual(defaultBody.order, [
451
+ { field: 'instrumentCode', direction: 'asc' },
452
+ ]);
453
+ assert.equal(
454
+ [...defaultBody.filters, ...defaultBody.order].every(item =>
455
+ declaredFields.has(item.field)
456
+ ),
457
+ true
458
+ );
234
459
  });
460
+
461
+ test('list rejects an undeclared sorter before issuing a Data API request', async () => {
462
+ let requests = 0;
463
+ const originalFetch = globalThis.fetch;
464
+ globalThis.fetch = async () => {
465
+ requests += 1;
466
+ throw new Error('unexpected fetch');
467
+ };
468
+ try {
469
+ await assert.rejects(
470
+ instrumentDataApi.list({
471
+ page: 1,
472
+ pageSize: 20,
473
+ sort: { field: 'updatedAt', order: 'desc' },
474
+ }),
475
+ /OPENXIANGDA_INSTRUMENT_QUERY_FIELD_NOT_DECLARED:updatedAt/
476
+ );
477
+ } finally {
478
+ globalThis.fetch = originalFetch;
479
+ }
480
+ assert.equal(requests, 0);
481
+ });
482
+
483
+ test('authoritative selectors page, resolve exact IDs, and never send RoleSession', async () => {
484
+ const calls: Array<{ path: string; init?: RequestInit }> = [];
485
+ const originalFetch = globalThis.fetch;
486
+ globalThis.fetch = async (input, init) => {
487
+ const path = String(input);
488
+ calls.push({ path, init });
489
+ if (path.endsWith('/dev-sessions/current')) {
490
+ return Response.json({
491
+ code: 200,
492
+ data: {
493
+ environment: {
494
+ id: 'test-id',
495
+ key: 'preproduction',
496
+ activeAppVersionId: 'version-1',
497
+ headRevision: 2,
498
+ authzRevisionId: 'authz-1',
499
+ authzVersion: 3,
500
+ scopeDataVersion: 'scope-1',
501
+ },
502
+ principal: {
503
+ userId,
504
+ roleCodes: ['school_admin'],
505
+ capabilityCodes: [],
506
+ isAppSuperAdmin: false,
507
+ },
508
+ },
509
+ });
510
+ }
511
+ if (path.includes('/directory/users?')) {
512
+ return Response.json({
513
+ code: 200,
514
+ data: {
515
+ schemaVersion: 'openxiangda.directory-entry-page/v2',
516
+ kind: 'user',
517
+ items: [
518
+ { kind: 'user', id: userId, label: '王老师', selectable: true },
519
+ ],
520
+ nextCursor: 'next-user-page',
521
+ },
522
+ });
523
+ }
524
+ if (path.includes('/directory/departments/tree?')) {
525
+ return Response.json({
526
+ code: 200,
527
+ data: {
528
+ schemaVersion: 'openxiangda.directory-entry-page/v2',
529
+ kind: 'department',
530
+ items: [
531
+ {
532
+ kind: 'department',
533
+ id: 'department-root',
534
+ label: '材料学院',
535
+ hasChildren: true,
536
+ selectable: true,
537
+ },
538
+ ],
539
+ nextCursor: null,
540
+ },
541
+ });
542
+ }
543
+ if (path.includes('/directory/departments/department-root/users?')) {
544
+ return Response.json({
545
+ code: 200,
546
+ data: {
547
+ schemaVersion: 'openxiangda.directory-entry-page/v2',
548
+ kind: 'user',
549
+ items: [
550
+ { kind: 'user', id: userId, label: '王老师', selectable: true },
551
+ ],
552
+ nextCursor: null,
553
+ },
554
+ });
555
+ }
556
+ if (path.includes('/directory/resolve?')) {
557
+ const body = JSON.parse(String(init?.body));
558
+ return Response.json({
559
+ code: 200,
560
+ data: {
561
+ schemaVersion: 'openxiangda.directory-entry-page/v2',
562
+ kind: body.kind,
563
+ items: body.ids.map((id: string) => ({
564
+ kind: body.kind,
565
+ id,
566
+ label: '精确解析',
567
+ selectable: false,
568
+ })),
569
+ nextCursor: null,
570
+ },
571
+ });
572
+ }
573
+ if (path.includes('/scope-values/resolve?')) {
574
+ const body = JSON.parse(String(init?.body));
575
+ return Response.json({
576
+ code: 200,
577
+ data: scopePage(
578
+ body.operation,
579
+ body.values.map((value: string) => ({
580
+ value,
581
+ label: '先进材料学院',
582
+ selectable: false,
583
+ }))
584
+ ),
585
+ });
586
+ }
587
+ if (path.includes('/scope-values?')) {
588
+ return Response.json({
589
+ code: 200,
590
+ data: scopePage('create', [
591
+ { value: collegeId, label: '先进材料学院', selectable: true },
592
+ ]),
593
+ });
594
+ }
595
+ return Response.json({ code: 404, message: 'not mocked' }, { status: 404 });
596
+ };
597
+ try {
598
+ await loadCurrentIdentity();
599
+ const users = await searchDirectory('user', {
600
+ keyword: '王老',
601
+ cursor: 'cursor-1',
602
+ });
603
+ assert.equal(users.nextCursor, 'next-user-page');
604
+ assert.equal((await resolveDirectory('department', [userId])).items[0]?.selectable, false);
605
+ assert.equal(
606
+ (await browseDepartmentTree({ parentId: 'department-parent' })).items[0]
607
+ ?.id,
608
+ 'department-root'
609
+ );
610
+ assert.equal(
611
+ (await browseDepartmentUsers('department-root', 2)).items[0]?.id,
612
+ userId
613
+ );
614
+ assert.equal(
615
+ (await searchCollegeScope('create', { keyword: '', cursor: 'scope-1' }))
616
+ .items[0]?.value,
617
+ collegeId
618
+ );
619
+ assert.equal(
620
+ (await resolveCollegeScope('update', [collegeId])).items[0]?.selectable,
621
+ false
622
+ );
623
+ await assert.rejects(
624
+ resolveDirectory('user', []),
625
+ /OPENXIANGDA_DIRECTORY_RESOLVE_IDS_REQUIRED/
626
+ );
627
+ await assert.rejects(
628
+ resolveCollegeScope('update', []),
629
+ /OPENXIANGDA_SCOPE_RESOLVE_VALUES_REQUIRED/
630
+ );
631
+ await assert.rejects(
632
+ resolveDirectory(
633
+ 'user',
634
+ Array.from({ length: 51 }, (_, index) => `user-${index}`)
635
+ ),
636
+ /OPENXIANGDA_DIRECTORY_RESOLVE_IDS_EXCEEDED/
637
+ );
638
+ await assert.rejects(
639
+ resolveCollegeScope(
640
+ 'update',
641
+ Array.from({ length: 51 }, (_, index) =>
642
+ `${String(index).padStart(8, '0')}-0000-4000-8000-000000000000`
643
+ )
644
+ ),
645
+ /OPENXIANGDA_SCOPE_RESOLVE_VALUES_EXCEEDED/
646
+ );
647
+ } finally {
648
+ globalThis.fetch = originalFetch;
649
+ }
650
+ const userSearch = calls.find(call => call.path.includes('/directory/users?'))!;
651
+ const treeBrowse = calls.find(call => call.path.includes('/directory/departments/tree?'))!;
652
+ const memberBrowse = calls.find(call => call.path.includes('/directory/departments/department-root/users?'))!;
653
+ const scopeSearch = calls.find(call => call.path.includes('/scope-values?'))!;
654
+ assert.match(userSearch.path, /keyword=%E7%8E%8B%E8%80%81/);
655
+ assert.match(userSearch.path, /cursor=cursor-1/);
656
+ assert.match(treeBrowse.path, /parentId=department-parent/);
657
+ assert.match(memberBrowse.path, /page=2/);
658
+ assert.match(scopeSearch.path, /environmentKey=preproduction/);
659
+ assert.match(
660
+ scopeSearch.path,
661
+ /\/native\/data-resources\/instruments\/fields\/collegeId\/scope-values\?/
662
+ );
663
+ assert.match(scopeSearch.path, /operation=create/);
664
+ assert.match(scopeSearch.path, /cursor=scope-1/);
665
+ assert.equal(
666
+ calls.some(call =>
667
+ Object.keys(call.init?.headers || {}).some(header =>
668
+ header.toLowerCase().includes('role-session')
669
+ )
670
+ ),
671
+ false
672
+ );
673
+ });
674
+
675
+ function scopePage(
676
+ operation: 'create' | 'update',
677
+ items: Array<{ value: string; label: string; selectable: boolean }>
678
+ ) {
679
+ return {
680
+ schemaVersion: 'openxiangda.native-scope-value-page/v2',
681
+ environment: {
682
+ id: 'test-id',
683
+ key: 'preproduction',
684
+ activeAppVersionId: 'version-1',
685
+ headRevision: 2,
686
+ authzRevisionId: 'authz-1',
687
+ authzVersion: 3,
688
+ scopeDataVersion: 'scope-1',
689
+ },
690
+ resourceCode: 'instruments',
691
+ fieldCode: 'collegeId',
692
+ dimensionCode: 'college',
693
+ operation,
694
+ items,
695
+ nextCursor: null,
696
+ };
697
+ }
@@ -3,12 +3,17 @@ import {
3
3
  instruments,
4
4
  protectedFieldCapabilities,
5
5
  } from './platform/data/instruments.js';
6
+ import { colleges } from './platform/data/colleges.js';
6
7
 
7
8
  const read = 'app:instrument-center:data:instruments:read';
8
9
  const create = 'app:instrument-center:data:instruments:create';
9
10
  const update = 'app:instrument-center:data:instruments:update';
10
11
  const remove = 'app:instrument-center:data:instruments:delete';
11
12
  const inspectContext = 'app:instrument-center:instrument:context:read';
13
+ const directoryRead = 'app:instrument-center:directory:read';
14
+ const collegeRead = 'app:instrument-center:data:colleges:read';
15
+ const collegeCreate = 'app:instrument-center:data:colleges:create';
16
+ const collegeUpdate = 'app:instrument-center:data:colleges:update';
12
17
  const allProtectedFieldCapabilities = Object.values(
13
18
  protectedFieldCapabilities
14
19
  ).flatMap(capabilities => [capabilities.create, capabilities.update]);
@@ -26,6 +31,7 @@ export default defineOpenXiangdaApp({
26
31
  root: 'apps/web',
27
32
  routes: [
28
33
  { code: 'instruments', path: '/instruments', label: '仪器资源', surface: 'admin', navigation: 'menu', capability: read },
34
+ { code: 'colleges', path: '/colleges', label: '学院字典', surface: 'admin', navigation: 'menu', capability: collegeRead },
29
35
  { code: 'instrument-create', path: '/instruments/new', label: '新增仪器', surface: 'admin', navigation: 'hidden', parentCode: 'instruments', capability: create },
30
36
  { code: 'instrument-edit', path: '/instruments/:id/edit', label: '编辑仪器', surface: 'admin', navigation: 'hidden', parentCode: 'instruments', capability: update },
31
37
  { code: 'instrument-detail', path: '/instruments/:id', label: '仪器详情', surface: 'admin', navigation: 'hidden', parentCode: 'instruments', capability: read },
@@ -54,11 +60,24 @@ export default defineOpenXiangdaApp({
54
60
  { code: inspectContext, kind: 'backend', name: '查看当前应用身份摘要' },
55
61
  ],
56
62
  roles: [
57
- { code: 'school_admin', name: '学校管理员', capabilities: [read, create, update, remove, ...allProtectedFieldCapabilities, inspectContext] },
58
- { code: 'college_admin', name: '学院管理员', capabilities: [read, create, update, remove, ...collegeCreateCapabilities, ...collegeUpdateCapabilities, inspectContext] },
59
- { code: 'instrument_admin', name: '仪器管理员', capabilities: [read, update, inspectContext] },
63
+ { code: 'school_admin', name: '学校管理员', capabilities: [read, create, update, remove, collegeRead, collegeCreate, collegeUpdate, directoryRead, ...allProtectedFieldCapabilities, inspectContext] },
64
+ { code: 'college_admin', name: '学院管理员', capabilities: [read, create, update, remove, directoryRead, ...collegeCreateCapabilities, ...collegeUpdateCapabilities, inspectContext] },
65
+ { code: 'instrument_admin', name: '仪器管理员', capabilities: [read, update, directoryRead, inspectContext] },
66
+ ],
67
+ scopeDimensions: [
68
+ {
69
+ code: 'college',
70
+ name: '学院',
71
+ valueType: 'uuid',
72
+ hierarchyMode: 'flat',
73
+ valueSource: {
74
+ kind: 'native_resource',
75
+ resourceCode: 'colleges',
76
+ labelField: 'name',
77
+ enabledField: 'enabled',
78
+ },
79
+ },
60
80
  ],
61
- scopeDimensions: [{ code: 'college', name: '学院', valueType: 'string', hierarchyMode: 'flat' }],
62
81
  dataPolicies: [
63
82
  {
64
83
  code: 'instrument-access',
@@ -73,5 +92,5 @@ export default defineOpenXiangdaApp({
73
92
  },
74
93
  ],
75
94
  },
76
- data: { resources: [instruments] },
95
+ data: { resources: [colleges, instruments] },
77
96
  });
@@ -21,9 +21,9 @@
21
21
  "build": "pnpm --recursive build"
22
22
  },
23
23
  "devDependencies": {
24
- "openxiangda-cli": "2.0.0-alpha.57",
25
- "openxiangda-contracts": "2.0.0-alpha.26",
26
- "openxiangda-devkit-core": "2.0.0-alpha.33",
24
+ "openxiangda-cli": "2.0.0-alpha.59",
25
+ "openxiangda-contracts": "2.0.0-alpha.28",
26
+ "openxiangda-devkit-core": "2.0.0-alpha.35",
27
27
  "typescript": "5.9.3"
28
28
  }
29
29
  }
@@ -2,9 +2,14 @@
2
2
  export const compilerContractVersion = "native-2" as const;
3
3
  export const appCode = "instrument-center" as const;
4
4
  export const resourceCodes = [
5
+ "colleges",
5
6
  "instruments"
6
7
  ] as const;
7
8
  export const capabilities = [
9
+ "app:instrument-center:data:colleges:create",
10
+ "app:instrument-center:data:colleges:delete",
11
+ "app:instrument-center:data:colleges:read",
12
+ "app:instrument-center:data:colleges:update",
8
13
  "app:instrument-center:data:instruments:create",
9
14
  "app:instrument-center:data:instruments:delete",
10
15
  "app:instrument-center:data:instruments:read",
@@ -39,6 +44,16 @@ export const appOperations = {
39
44
  }
40
45
  } as const;
41
46
  export const appRoutes = {
47
+ colleges: {
48
+ "code": "colleges",
49
+ "path": "/colleges",
50
+ "label": "学院字典",
51
+ "surface": "admin",
52
+ "navigation": "menu",
53
+ "capability": "app:instrument-center:data:colleges:read",
54
+ "tabPersistence": "session",
55
+ "keepAlive": "none"
56
+ },
42
57
  instrumentCreate: {
43
58
  "code": "instrument-create",
44
59
  "path": "/instruments/new",
@@ -95,6 +110,13 @@ export type EventType = (typeof eventTypes)[number];
95
110
  export type EventSubscriptionCode = (typeof eventSubscriptionCodes)[number];
96
111
  export type WorkflowCode = (typeof workflowCodes)[number];
97
112
 
113
+ export interface CollegesRecord {
114
+ id: string;
115
+ revision: number;
116
+ name: string;
117
+ enabled: boolean;
118
+ }
119
+
98
120
  export interface InstrumentsRecord {
99
121
  id: string;
100
122
  revision: number;