openxiangda-cli 2.0.0-alpha.56 → 2.0.0-alpha.58

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 (36) 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/.dockerignore +11 -0
  10. package/template/README.md +3 -1
  11. package/template/apps/server/package.json +1 -1
  12. package/template/apps/web/e2e/instruments.spec.ts +912 -9
  13. package/template/apps/web/package.json +1 -1
  14. package/template/apps/web/scripts/check.mjs +2 -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/InstrumentDetailPage.tsx +94 -20
  18. package/template/apps/web/src/InstrumentForm.tsx +450 -88
  19. package/template/apps/web/src/InstrumentFormPage.tsx +49 -9
  20. package/template/apps/web/src/InstrumentListPage.tsx +30 -35
  21. package/template/apps/web/src/Shell.tsx +229 -41
  22. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +698 -0
  23. package/template/apps/web/src/data-provider.ts +1 -1
  24. package/template/apps/web/src/fields.ts +17 -6
  25. package/template/apps/web/src/instrument.ts +1 -1
  26. package/template/apps/web/src/main.tsx +16 -1
  27. package/template/apps/web/src/platform-client.ts +213 -2
  28. package/template/apps/web/src/runtime.tsx +4 -2
  29. package/template/apps/web/src/styles.css +869 -29
  30. package/template/apps/web/test/contracts.test.ts +369 -10
  31. package/template/openxiangda.config.ts +24 -5
  32. package/template/package.json +3 -3
  33. package/template/packages/contracts/src/generated.ts +22 -0
  34. package/template/platform/data/colleges.ts +21 -0
  35. package/template/platform/data/instruments.ts +1 -1
  36. package/template/scripts/verify-template-budget.mjs +1 -1
@@ -1,4 +1,35 @@
1
- import { expect, test } from '@playwright/test';
1
+ import {
2
+ expect,
3
+ test,
4
+ type Page,
5
+ type Request,
6
+ type Route,
7
+ } from '@playwright/test';
8
+ import {
9
+ instrumentFields,
10
+ } from '../src/fields';
11
+
12
+ const declaredInstrumentFields = new Set(
13
+ instrumentFields.filter(field => !field.system).map(field => field.key)
14
+ );
15
+ const collegeId = '11111111-1111-4111-8111-111111111111';
16
+ const userId = '22222222-2222-4222-8222-222222222222';
17
+ const departmentId = '33333333-3333-4333-8333-333333333333';
18
+
19
+ function expectDeclaredInstrumentQuery(request: Request) {
20
+ const body = request.postDataJSON() as {
21
+ filters?: Array<{ field: string }>;
22
+ order?: Array<{ field: string }>;
23
+ };
24
+ const fields = [
25
+ ...(body.filters || []).map(filter => filter.field),
26
+ ...(body.order || []).map(order => order.field),
27
+ ];
28
+ expect(fields.every(field => declaredInstrumentFields.has(field))).toBe(true);
29
+ expect(fields).not.toContain('updatedAt');
30
+ expect(fields).not.toContain('updated_at');
31
+ return body;
32
+ }
2
33
 
3
34
  test('renders the production warning and never requests a role session', async ({
4
35
  page,
@@ -44,7 +75,7 @@ test('renders the production warning and never requests a role session', async (
44
75
  },
45
76
  principal: {
46
77
  type: 'user',
47
- userId: 'user-e2e',
78
+ userId,
48
79
  roleCodes: ['college_admin', 'instrument_admin'],
49
80
  capabilityCodes: [
50
81
  'app:instrument-center:data:instruments:read',
@@ -59,9 +90,11 @@ test('renders the production warning and never requests a role session', async (
59
90
  return;
60
91
  }
61
92
  if (url.pathname.endsWith('/native/data/instruments/query')) {
93
+ expectDeclaredInstrumentQuery(route.request());
62
94
  expect(route.request().postDataJSON()).toMatchObject({
63
95
  schemaVersion: 'openxiangda.data-query/v2',
64
96
  environmentKey: 'production',
97
+ order: [{ field: 'instrumentCode', direction: 'asc' }],
65
98
  });
66
99
  await route.fulfill({
67
100
  contentType: 'application/json',
@@ -83,13 +116,13 @@ test('renders the production warning and never requests a role session', async (
83
116
  usageStatus: 'active',
84
117
  instrumentStatus: 'normal',
85
118
  openToOutside: true,
86
- collegeId: 'college-am',
87
- manageDepartmentId: 'dept-am-test',
88
- useDepartmentId: 'dept-am-test',
89
- instrumentAdminIds: ['user-e2e'],
90
- contactUserIds: ['user-e2e'],
119
+ collegeId,
120
+ manageDepartmentId: departmentId,
121
+ useDepartmentId: departmentId,
122
+ instrumentAdminIds: [userId],
123
+ contactUserIds: [userId],
91
124
  contactPhone: '13800138000',
92
- updatedAt: '2026-08-21T00:00:00.000Z',
125
+ updated_at: '2026-08-21T00:00:00.000Z',
93
126
  },
94
127
  ],
95
128
  total: 1,
@@ -100,6 +133,7 @@ test('renders the production warning and never requests a role session', async (
100
133
  });
101
134
  return;
102
135
  }
136
+ if (await fulfillSelector(route)) return;
103
137
  await route.fulfill({
104
138
  status: 404,
105
139
  contentType: 'application/json',
@@ -110,7 +144,10 @@ test('renders the production warning and never requests a role session', async (
110
144
  await expect(page.getByTestId('production-data-warning')).toBeVisible();
111
145
  await expect(page.getByTestId('instrument-title')).toHaveText('仪器资源');
112
146
  await expect(page.getByText('场发射扫描电子显微镜')).toBeVisible();
113
- await expect(page.getByText('college_admin、instrument_admin')).toBeVisible();
147
+ await expect(
148
+ page.getByRole('complementary').getByText('仪器管理', { exact: true })
149
+ ).toBeVisible();
150
+ await expect(page.getByText('王老师', { exact: true }).first()).toBeVisible();
114
151
  expect(requests.filter(url => url.includes('role-session'))).toHaveLength(0);
115
152
  expect(
116
153
  requests.some(url =>
@@ -119,6 +156,548 @@ test('renders the production warning and never requests a role session', async (
119
156
  ).toBe(true);
120
157
  });
121
158
 
159
+ test('continues create, list, detail, update, and delete with declared query fields', async ({
160
+ page,
161
+ }) => {
162
+ test.setTimeout(60_000);
163
+ let record: Record<string, unknown> | undefined;
164
+ const queryBodies: unknown[] = [];
165
+ const operations: string[] = [];
166
+ const protectedCapabilities = instrumentFields.flatMap(field => [
167
+ ...(field.createCapability ? [field.createCapability] : []),
168
+ ...(field.updateCapability ? [field.updateCapability] : []),
169
+ ]);
170
+
171
+ await installProductionRuntimeMeta(page);
172
+ await page.route('**/mock-upload/instrument-file-1', route =>
173
+ route.fulfill({ status: 200, body: '' })
174
+ );
175
+ await page.route('**/service/**', async route => {
176
+ const request = route.request();
177
+ const url = new URL(request.url());
178
+ const path = url.pathname;
179
+ if (
180
+ path.endsWith('/native/current-user') &&
181
+ url.searchParams.get('environmentKey') === 'production'
182
+ ) {
183
+ await route.fulfill({
184
+ contentType: 'application/json',
185
+ body: JSON.stringify({
186
+ code: 200,
187
+ data: {
188
+ schemaVersion: 'openxiangda.current-user/v2',
189
+ appCode: 'instrument-center',
190
+ environment: {
191
+ id: 'production-id',
192
+ key: 'production',
193
+ activeAppVersionId: 'app-version-1',
194
+ headRevision: 7,
195
+ authzRevisionId: 'authz-revision-1',
196
+ authzVersion: 3,
197
+ scopeDataVersion: 'scope-data-version-1',
198
+ },
199
+ principal: {
200
+ type: 'user',
201
+ userId,
202
+ roleCodes: ['school_admin'],
203
+ capabilityCodes: [
204
+ 'app:instrument-center:data:instruments:read',
205
+ 'app:instrument-center:data:instruments:create',
206
+ 'app:instrument-center:data:instruments:update',
207
+ 'app:instrument-center:data:instruments:delete',
208
+ ...protectedCapabilities,
209
+ ],
210
+ isAppSuperAdmin: false,
211
+ },
212
+ },
213
+ }),
214
+ });
215
+ return;
216
+ }
217
+ if (await fulfillSelector(route)) return;
218
+ if (path.endsWith('/native/data/instruments/query')) {
219
+ queryBodies.push(expectDeclaredInstrumentQuery(request));
220
+ operations.push('query');
221
+ await route.fulfill({
222
+ contentType: 'application/json',
223
+ body: JSON.stringify({
224
+ code: 200,
225
+ data: {
226
+ schemaVersion: 'openxiangda.data-page/v2',
227
+ resourceCode: 'instruments',
228
+ items: record ? [record] : [],
229
+ total: record ? 1 : 0,
230
+ limit: 20,
231
+ offset: 0,
232
+ },
233
+ }),
234
+ });
235
+ return;
236
+ }
237
+ if (path.endsWith('/files/uploads/initiate')) {
238
+ const body = request.postDataJSON() as {
239
+ fieldCode: string;
240
+ fileName: string;
241
+ fileSize: number;
242
+ };
243
+ expect(body).toMatchObject({
244
+ fieldCode: 'attachments',
245
+ fileName: '设备说明书.pdf',
246
+ });
247
+ operations.push('upload-initiate');
248
+ await route.fulfill({
249
+ contentType: 'application/json',
250
+ body: JSON.stringify({
251
+ code: 200,
252
+ data: {
253
+ schemaVersion: 'openxiangda.data-file-upload-plan/v2',
254
+ resourceCode: 'instruments',
255
+ fieldCode: 'attachments',
256
+ file: {
257
+ schemaVersion: 'openxiangda.data-file-ref/v2',
258
+ id: 'instrument-file-1',
259
+ name: body.fileName,
260
+ size: body.fileSize,
261
+ contentType: 'application/pdf',
262
+ },
263
+ uploadMethod: 'PUT',
264
+ uploadUrl: `${url.origin}/mock-upload/instrument-file-1`,
265
+ headers: {},
266
+ expiresAt: '2026-08-21T18:00:00.000Z',
267
+ },
268
+ }),
269
+ });
270
+ return;
271
+ }
272
+ if (path.endsWith('/files/instrument-file-1/complete')) {
273
+ operations.push('upload-complete');
274
+ await route.fulfill({
275
+ contentType: 'application/json',
276
+ body: JSON.stringify({
277
+ code: 200,
278
+ data: {
279
+ schemaVersion: 'openxiangda.data-file-ref/v2',
280
+ id: 'instrument-file-1',
281
+ name: '设备说明书.pdf',
282
+ size: 2048,
283
+ contentType: 'application/pdf',
284
+ },
285
+ }),
286
+ });
287
+ return;
288
+ }
289
+ if (path.endsWith('/native/data/instruments/records')) {
290
+ const body = request.postDataJSON() as {
291
+ environmentKey: string;
292
+ data: Record<string, unknown>;
293
+ };
294
+ expect(body.environmentKey).toBe('production');
295
+ expect(body.data.attachments).toEqual([
296
+ {
297
+ schemaVersion: 'openxiangda.data-file-ref/v2',
298
+ id: 'instrument-file-1',
299
+ name: '设备说明书.pdf',
300
+ size: 2048,
301
+ contentType: 'application/pdf',
302
+ },
303
+ ]);
304
+ record = {
305
+ ...body.data,
306
+ id: '092b0000-0000-4000-8000-000000000001',
307
+ revision: 1,
308
+ updated_at: '2026-08-21T01:00:00.000Z',
309
+ };
310
+ operations.push('create');
311
+ await fulfillRecord(route, record);
312
+ return;
313
+ }
314
+ if (path.endsWith('/update')) {
315
+ const body = request.postDataJSON() as {
316
+ environmentKey: string;
317
+ expectedRevision: number;
318
+ data: Record<string, unknown>;
319
+ };
320
+ expect(body.environmentKey).toBe('production');
321
+ expect(body.expectedRevision).toBe(record?.revision);
322
+ record = {
323
+ ...record,
324
+ ...body.data,
325
+ revision: Number(record?.revision) + 1,
326
+ updated_at: '2026-08-21T02:00:00.000Z',
327
+ };
328
+ operations.push('update');
329
+ await fulfillRecord(route, record);
330
+ return;
331
+ }
332
+ if (path.endsWith('/delete')) {
333
+ const deleted = record!;
334
+ const body = request.postDataJSON() as {
335
+ environmentKey: string;
336
+ expectedRevision: number;
337
+ };
338
+ expect(body.environmentKey).toBe('production');
339
+ expect(body.expectedRevision).toBe(record?.revision);
340
+ record = undefined;
341
+ operations.push('delete');
342
+ await fulfillRecord(route, deleted);
343
+ return;
344
+ }
345
+ if (path.endsWith('/audit')) {
346
+ await route.fulfill({
347
+ contentType: 'application/json',
348
+ body: JSON.stringify({
349
+ code: 200,
350
+ data: {
351
+ schemaVersion: 'openxiangda.data-audit-page/v2',
352
+ resourceCode: 'instruments',
353
+ recordId: record?.id,
354
+ items: [],
355
+ total: 0,
356
+ limit: 10,
357
+ offset: 0,
358
+ },
359
+ }),
360
+ });
361
+ return;
362
+ }
363
+ if (path.includes('/native/data/instruments/records/')) {
364
+ operations.push('get');
365
+ if (!record) {
366
+ await route.fulfill({
367
+ status: 404,
368
+ contentType: 'application/json',
369
+ body: JSON.stringify({ code: 404, message: 'not found' }),
370
+ });
371
+ return;
372
+ }
373
+ await fulfillRecord(route, record);
374
+ return;
375
+ }
376
+ await route.fulfill({
377
+ status: 404,
378
+ contentType: 'application/json',
379
+ body: JSON.stringify({ code: 404, message: 'not mocked' }),
380
+ });
381
+ });
382
+
383
+ await page.goto('/view/instrument-center/instruments/new');
384
+ await expect(
385
+ page.getByRole('heading', { name: '新增仪器', level: 2 })
386
+ ).toBeVisible();
387
+ await expect(page.getByText('管理与联系')).toBeVisible();
388
+ await expect(page.getByText('地址与资料')).toBeVisible();
389
+ await page.getByLabel('仪器编码').fill('INS-LIVE-001');
390
+ await page.getByLabel('仪器中文名').fill('原位电子显微镜');
391
+ await page.getByLabel('规格型号').fill('EM-LIVE');
392
+ await chooseOption(page, '仪器分类', '显微成像');
393
+ await page.getByLabel('资产编码').fill('ASSET-LIVE-001');
394
+ await page.getByLabel('启用日期').fill('2026-08-21');
395
+ await chooseOption(page, '所属学院', '先进材料学院');
396
+ await pickDirectoryDepartment(page, '管理部门', '材料测试中心');
397
+ await pickDirectoryDepartment(page, '使用部门', '材料测试中心');
398
+ await pickDirectoryMember(page, '仪器管理员', '王老师');
399
+ await pickDirectoryMember(page, '联系人', '王老师');
400
+ await page.getByLabel('联系电话').fill('13800138000');
401
+ await page
402
+ .locator('.oxa-file-field input[type="file"]')
403
+ .last()
404
+ .setInputFiles({
405
+ name: '设备说明书.pdf',
406
+ mimeType: 'application/pdf',
407
+ buffer: Buffer.from('openxiangda instrument manual'),
408
+ });
409
+ await expect(page.getByText('设备说明书.pdf')).toBeVisible();
410
+ await page.getByTestId('save-instrument').click();
411
+
412
+ await expect(page).toHaveURL(/\/view\/instrument-center\/instruments$/);
413
+ await expect(page.getByText('原位电子显微镜')).toBeVisible();
414
+ await expect(page.locator('.ant-table-tbody')).toContainText('2026');
415
+ await expect(
416
+ page
417
+ .getByRole('columnheader', { name: '更新时间' })
418
+ .locator('.ant-table-column-sorter')
419
+ ).toHaveCount(0);
420
+ const queriesBeforeSort = queryBodies.length;
421
+ await page.getByRole('columnheader', { name: /仪器名称/ }).click();
422
+ await expect.poll(() => queryBodies.length).toBeGreaterThan(queriesBeforeSort);
423
+ expect(
424
+ (queryBodies.at(-1) as { order: Array<{ field: string; direction: string }> })
425
+ .order
426
+ ).toEqual([{ field: 'chineseName', direction: 'asc' }]);
427
+ await page.getByText('原位电子显微镜').click();
428
+ await expect(
429
+ page.getByRole('heading', { name: '仪器详情', level: 2 })
430
+ ).toBeVisible();
431
+ await expect(page.getByText('记录信息')).toBeVisible();
432
+ await expect(page.getByText('设备说明书.pdf')).toBeVisible();
433
+ await page.getByRole('button', { name: /编.*辑/ }).click();
434
+ await expect(page.getByRole('dialog')).toHaveCount(0);
435
+ await expect(page.getByText('管理与联系')).toBeVisible();
436
+ await page.getByLabel('仪器中文名').fill('原位电子显微镜(更新)');
437
+ await page.getByTestId('save-instrument').click();
438
+ await expect(
439
+ page.getByRole('heading', { name: '原位电子显微镜(更新)' })
440
+ ).toBeVisible();
441
+ await page.getByRole('button', { name: '返回列表' }).click();
442
+ await expect(page).toHaveURL(/\/view\/instrument-center\/instruments$/);
443
+ await expect(
444
+ page.locator('.ant-table-tbody').getByText('原位电子显微镜(更新)')
445
+ ).toBeVisible();
446
+ await page.getByLabel('删除').click();
447
+ await page.getByRole('button', { name: /确.*定/ }).click();
448
+ await expect(
449
+ page.locator('.ant-table-tbody').getByText('原位电子显微镜(更新)')
450
+ ).toHaveCount(0);
451
+
452
+ await page.setViewportSize({ width: 760, height: 900 });
453
+ await page.goto('/view/instrument-center/instruments/new');
454
+ await expect(
455
+ page.getByRole('heading', { name: '新增仪器', level: 2 })
456
+ ).toBeVisible();
457
+ await expect(page.getByRole('dialog', { name: '新增仪器' })).toHaveCount(0);
458
+ await expect(page.getByRole('button', { name: '展开侧边栏' })).toBeVisible();
459
+
460
+ expect(queryBodies.length).toBeGreaterThanOrEqual(2);
461
+ expect(operations).toEqual(
462
+ expect.arrayContaining([
463
+ 'upload-initiate',
464
+ 'upload-complete',
465
+ 'create',
466
+ 'query',
467
+ 'get',
468
+ 'update',
469
+ 'delete',
470
+ ])
471
+ );
472
+ });
473
+
474
+ test('college admin can choose only granted college on create and cannot reassign on update', async ({
475
+ page,
476
+ }) => {
477
+ const requests: string[] = [];
478
+ page.on('request', request => requests.push(request.url()));
479
+ const protectedCreate = instrumentFields.flatMap(field =>
480
+ field.createCapability ? [field.createCapability] : []
481
+ );
482
+ const protectedUpdate = instrumentFields.flatMap(field =>
483
+ field.updateCapability && field.key !== 'collegeId'
484
+ ? [field.updateCapability]
485
+ : []
486
+ );
487
+ await installProductionRuntimeMeta(page);
488
+ await page.route('**/service/**', async route => {
489
+ const url = new URL(route.request().url());
490
+ if (url.pathname.endsWith('/native/current-user')) {
491
+ await fulfillIdentity(route, 'college_admin', [
492
+ 'app:instrument-center:data:instruments:read',
493
+ 'app:instrument-center:data:instruments:create',
494
+ 'app:instrument-center:data:instruments:update',
495
+ ...protectedCreate,
496
+ ...protectedUpdate,
497
+ ]);
498
+ return;
499
+ }
500
+ if (await fulfillSelector(route)) return;
501
+ if (url.pathname.includes('/native/data/instruments/records/')) {
502
+ await fulfillRecord(route, instrumentFixture());
503
+ return;
504
+ }
505
+ await route.fulfill({ status: 404, body: '{}' });
506
+ });
507
+
508
+ await page.goto('/view/instrument-center/instruments/new');
509
+ await expect(page.getByLabel('所属学院')).toBeEnabled();
510
+ await chooseOption(page, '所属学院', '先进材料学院');
511
+ await expect(page.getByLabel('所属学院')).toHaveValue('');
512
+ await expect(page.getByLabel('仪器管理员')).toBeEnabled();
513
+
514
+ await page.goto(
515
+ '/view/instrument-center/instruments/092b0000-0000-4000-8000-000000000001/edit'
516
+ );
517
+ await expect(page.getByLabel('所属学院')).toBeDisabled();
518
+ await expect(page.getByLabel('仪器管理员')).toBeEnabled();
519
+ expect(requests.filter(url => url.includes('role-session'))).toHaveLength(0);
520
+ });
521
+
522
+ test('instrument admin edits ordinary fields but every protected field stays disabled', async ({
523
+ page,
524
+ }) => {
525
+ const requests: string[] = [];
526
+ page.on('request', request => requests.push(request.url()));
527
+ await installProductionRuntimeMeta(page);
528
+ await page.route('**/service/**', async route => {
529
+ const url = new URL(route.request().url());
530
+ if (url.pathname.endsWith('/native/current-user')) {
531
+ await fulfillIdentity(route, 'instrument_admin', [
532
+ 'app:instrument-center:data:instruments:read',
533
+ 'app:instrument-center:data:instruments:update',
534
+ ]);
535
+ return;
536
+ }
537
+ if (await fulfillSelector(route)) return;
538
+ if (url.pathname.includes('/native/data/instruments/records/')) {
539
+ await fulfillRecord(route, instrumentFixture());
540
+ return;
541
+ }
542
+ await route.fulfill({ status: 404, body: '{}' });
543
+ });
544
+
545
+ await page.goto(
546
+ '/view/instrument-center/instruments/092b0000-0000-4000-8000-000000000001/edit'
547
+ );
548
+ for (const label of [
549
+ '仪器编码',
550
+ '资产编码',
551
+ '资产价值(元)',
552
+ '所属学院',
553
+ '仪器管理员',
554
+ ]) {
555
+ await expect(page.getByLabel(label)).toBeDisabled();
556
+ }
557
+ await expect(page.getByLabel('仪器中文名')).toBeEnabled();
558
+ expect(requests.filter(url => url.includes('role-session'))).toHaveLength(0);
559
+ });
560
+
561
+ test('directory selector debounces search and keeps empty or failed results honest', async ({
562
+ page,
563
+ }) => {
564
+ const keywords: string[] = [];
565
+ await installProductionRuntimeMeta(page);
566
+ await page.route('**/service/**', async route => {
567
+ const url = new URL(route.request().url());
568
+ if (url.pathname.endsWith('/native/current-user')) {
569
+ await fulfillIdentity(route, 'school_admin', [
570
+ 'app:instrument-center:data:instruments:read',
571
+ 'app:instrument-center:data:instruments:create',
572
+ ...instrumentFields.flatMap(field =>
573
+ field.createCapability ? [field.createCapability] : []
574
+ ),
575
+ ]);
576
+ return;
577
+ }
578
+ if (url.pathname.endsWith('/directory/users')) {
579
+ const keyword = url.searchParams.get('keyword') || '';
580
+ keywords.push(keyword);
581
+ if (keyword === '错误词') {
582
+ await route.fulfill({
583
+ status: 503,
584
+ contentType: 'application/json',
585
+ body: JSON.stringify({
586
+ code: 503,
587
+ errorCode: 'DIRECTORY_UNAVAILABLE',
588
+ message: '目录暂不可用',
589
+ }),
590
+ });
591
+ return;
592
+ }
593
+ await route.fulfill({
594
+ contentType: 'application/json',
595
+ body: JSON.stringify({
596
+ code: 200,
597
+ data: directoryPage('user', []),
598
+ }),
599
+ });
600
+ return;
601
+ }
602
+ if (await fulfillSelector(route)) return;
603
+ await route.fulfill({ status: 404, body: '{}' });
604
+ });
605
+
606
+ await page.goto('/view/instrument-center/instruments/new');
607
+ await page.getByRole('button', { name: /仪器管理员/ }).click();
608
+ const dialog = page.getByRole('dialog', { name: '选择成员' });
609
+ const input = dialog.getByPlaceholder('搜索成员姓名或工号');
610
+ await input.fill('王老师');
611
+ await input.fill('李老师');
612
+ await expect.poll(() => keywords).toEqual(['李老师']);
613
+ await expect(dialog.getByText('暂无成员')).toBeVisible();
614
+ await input.fill('错误词');
615
+ await expect.poll(() => keywords.at(-1)).toBe('错误词');
616
+ await expect(dialog.getByText(/DIRECTORY_UNAVAILABLE/)).toBeVisible();
617
+ await expect(dialog.getByText('王老师')).toHaveCount(0);
618
+ });
619
+
620
+ test('school admin maintains the app-owned college UUID catalog without delete', async ({
621
+ page,
622
+ }) => {
623
+ let colleges = [
624
+ { id: collegeId, revision: 1, name: '先进材料学院', enabled: true },
625
+ ];
626
+ const operations: string[] = [];
627
+ await installProductionRuntimeMeta(page);
628
+ await page.route('**/service/**', async route => {
629
+ const request = route.request();
630
+ const url = new URL(request.url());
631
+ if (url.pathname.endsWith('/native/current-user')) {
632
+ await fulfillIdentity(route, 'school_admin', [
633
+ 'app:instrument-center:data:colleges:read',
634
+ 'app:instrument-center:data:colleges:create',
635
+ 'app:instrument-center:data:colleges:update',
636
+ ]);
637
+ return;
638
+ }
639
+ if (url.pathname.endsWith('/native/data/colleges/query')) {
640
+ operations.push('query');
641
+ await route.fulfill({
642
+ contentType: 'application/json',
643
+ body: JSON.stringify({
644
+ code: 200,
645
+ data: {
646
+ schemaVersion: 'openxiangda.data-page/v2',
647
+ resourceCode: 'colleges',
648
+ items: colleges,
649
+ total: colleges.length,
650
+ limit: 20,
651
+ offset: 0,
652
+ },
653
+ }),
654
+ });
655
+ return;
656
+ }
657
+ if (url.pathname.endsWith(`/native/data/colleges/records/${collegeId}/update`)) {
658
+ const body = request.postDataJSON() as {
659
+ expectedRevision: number;
660
+ data: { name: string; enabled: boolean };
661
+ };
662
+ expect(body.expectedRevision).toBe(1);
663
+ colleges = [{ ...colleges[0]!, ...body.data, revision: 2 }];
664
+ operations.push('update');
665
+ await fulfillCollegeRecord(route, colleges[0]!);
666
+ return;
667
+ }
668
+ if (url.pathname.endsWith('/native/data/colleges/records')) {
669
+ const body = request.postDataJSON() as {
670
+ data: { name: string; enabled: boolean };
671
+ };
672
+ const created = {
673
+ id: '44444444-4444-4444-8444-444444444444',
674
+ revision: 1,
675
+ ...body.data,
676
+ };
677
+ colleges = [...colleges, created];
678
+ operations.push('create');
679
+ await fulfillCollegeRecord(route, created);
680
+ return;
681
+ }
682
+ await route.fulfill({ status: 404, body: '{}' });
683
+ });
684
+
685
+ await page.goto('/view/instrument-center/colleges');
686
+ await expect(page.getByText('先进材料学院')).toBeVisible();
687
+ await page.getByRole('button', { name: /编.*辑/ }).click();
688
+ await page.getByLabel('学院名称').fill('材料科学学院');
689
+ await page.getByRole('button', { name: /保.*存/ }).click();
690
+ await expect(page.getByText('材料科学学院')).toBeVisible();
691
+ await page.getByRole('button', { name: /新.*增学院/ }).click();
692
+ await page.getByLabel('学院名称').fill('生命科学学院');
693
+ await page.getByRole('button', { name: /保.*存/ }).click();
694
+ await expect(page.getByText('生命科学学院')).toBeVisible();
695
+ await expect(page.getByRole('button', { name: /删.*除/ })).toHaveCount(0);
696
+ expect(operations).toEqual(
697
+ expect.arrayContaining(['query', 'update', 'create'])
698
+ );
699
+ });
700
+
122
701
  test('keeps the production warning visible when current-user fails closed', async ({
123
702
  page,
124
703
  }) => {
@@ -148,3 +727,327 @@ test('keeps the production warning visible when current-user fails closed', asyn
148
727
  await expect(page.getByTestId('production-data-warning')).toBeVisible();
149
728
  await expect(page.getByText('无法读取平台当前用户')).toBeVisible();
150
729
  });
730
+
731
+ async function installProductionRuntimeMeta(page: Page) {
732
+ await page.route('**/view/instrument-center/**', async route => {
733
+ if (route.request().resourceType() !== 'document') {
734
+ await route.continue();
735
+ return;
736
+ }
737
+ const response = await route.fetch();
738
+ const body = (await response.text()).replace(
739
+ '<head>',
740
+ `<head>
741
+ <meta name="openxiangda-runtime-base" content="/view/instrument-center/" />
742
+ <meta name="openxiangda-app-code" content="instrument-center" />
743
+ <meta name="openxiangda-environment" content="production" />`
744
+ );
745
+ await route.fulfill({ response, body });
746
+ });
747
+ }
748
+
749
+ async function chooseOption(page: Page, label: string, option: string) {
750
+ const input = page.getByLabel(label);
751
+ await input.focus();
752
+ await input.press('ArrowDown');
753
+ await expect(
754
+ page.getByRole('option', { name: option, exact: true })
755
+ ).toHaveCount(1);
756
+ await input.press('Enter');
757
+ await input.press('Escape');
758
+ }
759
+
760
+ async function pickDirectoryMember(
761
+ page: Page,
762
+ label: string,
763
+ option: string
764
+ ) {
765
+ const trigger = page.getByRole('button', { name: new RegExp(label) });
766
+ await trigger.click();
767
+ const dialog = page.getByRole('dialog', { name: '选择成员' });
768
+ await expect(dialog.getByText(option, { exact: true })).toBeVisible();
769
+ await dialog.getByText(option, { exact: true }).click();
770
+ await dialog.getByRole('button', { name: /确.*定/ }).click();
771
+ await expect(trigger).toContainText(option);
772
+ }
773
+
774
+ async function pickDirectoryDepartment(
775
+ page: Page,
776
+ label: string,
777
+ option: string
778
+ ) {
779
+ const trigger = page.getByRole('button', { name: new RegExp(label) });
780
+ await trigger.click();
781
+ const dialog = page.getByRole('dialog', { name: '选择部门' });
782
+ await dialog.getByText(option, { exact: true }).click();
783
+ await dialog.getByRole('button', { name: /确.*定/ }).click();
784
+ await expect(trigger).toContainText(option);
785
+ }
786
+
787
+ async function fulfillSelector(route: Route) {
788
+ const request = route.request();
789
+ const url = new URL(request.url());
790
+ if (url.pathname.endsWith('/directory/departments/tree')) {
791
+ await route.fulfill({
792
+ contentType: 'application/json',
793
+ body: JSON.stringify({
794
+ code: 200,
795
+ data: directoryPage('department', [
796
+ {
797
+ kind: 'department',
798
+ id: departmentId,
799
+ label: '材料测试中心',
800
+ description: '先进材料学院 / 材料测试中心',
801
+ path: [
802
+ { id: collegeId, label: '先进材料学院' },
803
+ { id: departmentId, label: '材料测试中心' },
804
+ ],
805
+ hasChildren: false,
806
+ selectable: true,
807
+ },
808
+ ]),
809
+ }),
810
+ });
811
+ return true;
812
+ }
813
+ if (
814
+ url.pathname.endsWith(
815
+ `/directory/departments/${encodeURIComponent(departmentId)}/users`
816
+ )
817
+ ) {
818
+ await route.fulfill({
819
+ contentType: 'application/json',
820
+ body: JSON.stringify({
821
+ code: 200,
822
+ data: directoryPage('user', [
823
+ {
824
+ kind: 'user',
825
+ id: userId,
826
+ label: '王老师',
827
+ description: '工号 10001 · 材料测试中心',
828
+ selectable: true,
829
+ },
830
+ ]),
831
+ }),
832
+ });
833
+ return true;
834
+ }
835
+ if (url.pathname.endsWith('/directory/users')) {
836
+ await route.fulfill({
837
+ contentType: 'application/json',
838
+ body: JSON.stringify({
839
+ code: 200,
840
+ data: directoryPage('user', [
841
+ { kind: 'user', id: userId, label: '王老师', selectable: true },
842
+ ]),
843
+ }),
844
+ });
845
+ return true;
846
+ }
847
+ if (url.pathname.endsWith('/directory/departments')) {
848
+ await route.fulfill({
849
+ contentType: 'application/json',
850
+ body: JSON.stringify({
851
+ code: 200,
852
+ data: directoryPage('department', [
853
+ {
854
+ kind: 'department',
855
+ id: departmentId,
856
+ label: '材料测试中心',
857
+ selectable: true,
858
+ },
859
+ ]),
860
+ }),
861
+ });
862
+ return true;
863
+ }
864
+ if (url.pathname.endsWith('/directory/resolve')) {
865
+ const body = request.postDataJSON() as {
866
+ kind: 'user' | 'department';
867
+ ids: string[];
868
+ };
869
+ await route.fulfill({
870
+ contentType: 'application/json',
871
+ body: JSON.stringify({
872
+ code: 200,
873
+ data: directoryPage(
874
+ body.kind,
875
+ body.ids.map(id => ({
876
+ kind: body.kind,
877
+ id,
878
+ label: body.kind === 'user' ? '王老师' : '材料测试中心',
879
+ selectable: true,
880
+ }))
881
+ ),
882
+ }),
883
+ });
884
+ return true;
885
+ }
886
+ if (url.pathname.endsWith('/scope-values')) {
887
+ await route.fulfill({
888
+ contentType: 'application/json',
889
+ body: JSON.stringify({
890
+ code: 200,
891
+ data: scopePage(url.searchParams.get('operation') || 'update', [
892
+ { value: collegeId, label: '先进材料学院', selectable: true },
893
+ ]),
894
+ }),
895
+ });
896
+ return true;
897
+ }
898
+ if (url.pathname.endsWith('/scope-values/resolve')) {
899
+ const body = request.postDataJSON() as {
900
+ operation: string;
901
+ values: string[];
902
+ };
903
+ await route.fulfill({
904
+ contentType: 'application/json',
905
+ body: JSON.stringify({
906
+ code: 200,
907
+ data: scopePage(
908
+ body.operation,
909
+ body.values.map(value => ({
910
+ value,
911
+ label: '先进材料学院',
912
+ selectable: true,
913
+ }))
914
+ ),
915
+ }),
916
+ });
917
+ return true;
918
+ }
919
+ return false;
920
+ }
921
+
922
+ function directoryPage(
923
+ kind: 'user' | 'department',
924
+ items: Array<{
925
+ kind: 'user' | 'department';
926
+ id: string;
927
+ label: string;
928
+ description?: string;
929
+ path?: Array<{ id: string; label: string }>;
930
+ hasChildren?: boolean;
931
+ selectable: boolean;
932
+ }>
933
+ ) {
934
+ return {
935
+ schemaVersion: 'openxiangda.directory-entry-page/v2',
936
+ kind,
937
+ items,
938
+ nextCursor: null,
939
+ };
940
+ }
941
+
942
+ function scopePage(
943
+ operation: string,
944
+ items: Array<{ value: string; label: string; selectable: boolean }>
945
+ ) {
946
+ return {
947
+ schemaVersion: 'openxiangda.native-scope-value-page/v2',
948
+ environment: {
949
+ id: 'production-id',
950
+ key: 'production',
951
+ activeAppVersionId: 'app-version-1',
952
+ headRevision: 7,
953
+ authzRevisionId: 'authz-revision-1',
954
+ authzVersion: 3,
955
+ scopeDataVersion: 'scope-data-version-1',
956
+ },
957
+ resourceCode: 'instruments',
958
+ fieldCode: 'collegeId',
959
+ dimensionCode: 'college',
960
+ operation,
961
+ items,
962
+ nextCursor: null,
963
+ };
964
+ }
965
+
966
+ async function fulfillRecord(
967
+ route: Route,
968
+ record: Record<string, unknown>
969
+ ) {
970
+ await route.fulfill({
971
+ contentType: 'application/json',
972
+ body: JSON.stringify({
973
+ code: 200,
974
+ data: {
975
+ schemaVersion: 'openxiangda.data-record/v2',
976
+ resourceCode: 'instruments',
977
+ data: record,
978
+ },
979
+ }),
980
+ });
981
+ }
982
+
983
+ async function fulfillCollegeRecord(
984
+ route: Route,
985
+ record: Record<string, unknown>
986
+ ) {
987
+ await route.fulfill({
988
+ contentType: 'application/json',
989
+ body: JSON.stringify({
990
+ code: 200,
991
+ data: {
992
+ schemaVersion: 'openxiangda.data-record/v2',
993
+ resourceCode: 'colleges',
994
+ data: record,
995
+ },
996
+ }),
997
+ });
998
+ }
999
+
1000
+ async function fulfillIdentity(
1001
+ route: Route,
1002
+ roleCode: string,
1003
+ capabilityCodes: string[]
1004
+ ) {
1005
+ await route.fulfill({
1006
+ contentType: 'application/json',
1007
+ body: JSON.stringify({
1008
+ code: 200,
1009
+ data: {
1010
+ schemaVersion: 'openxiangda.current-user/v2',
1011
+ appCode: 'instrument-center',
1012
+ environment: {
1013
+ id: 'production-id',
1014
+ key: 'production',
1015
+ activeAppVersionId: 'app-version-1',
1016
+ headRevision: 7,
1017
+ authzRevisionId: 'authz-revision-1',
1018
+ authzVersion: 3,
1019
+ scopeDataVersion: 'scope-data-version-1',
1020
+ },
1021
+ principal: {
1022
+ type: 'user',
1023
+ userId,
1024
+ roleCodes: [roleCode],
1025
+ capabilityCodes,
1026
+ isAppSuperAdmin: false,
1027
+ },
1028
+ },
1029
+ }),
1030
+ });
1031
+ }
1032
+
1033
+ function instrumentFixture() {
1034
+ return {
1035
+ id: '092b0000-0000-4000-8000-000000000001',
1036
+ revision: 2,
1037
+ instrumentCode: 'INS-001',
1038
+ chineseName: '场发射扫描电子显微镜',
1039
+ specModel: 'SU8600',
1040
+ categoryId: 'analysis_microscope',
1041
+ assetCode: 'ASSET-001',
1042
+ enabledDate: '2025-01-01',
1043
+ usageStatus: 'active',
1044
+ instrumentStatus: 'normal',
1045
+ openToOutside: true,
1046
+ collegeId,
1047
+ manageDepartmentId: departmentId,
1048
+ useDepartmentId: departmentId,
1049
+ instrumentAdminIds: [userId],
1050
+ contactUserIds: [userId],
1051
+ contactPhone: '13800138000',
1052
+ };
1053
+ }