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.
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +13 -5
- package/dist/commands/create.js.map +1 -1
- package/dist/create-workspace.d.ts +6 -1
- package/dist/create-workspace.d.ts.map +1 -1
- package/dist/create-workspace.js +12 -9
- package/dist/create-workspace.js.map +1 -1
- package/package.json +4 -4
- package/template/README.md +3 -1
- package/template/apps/server/package.json +1 -1
- package/template/apps/web/e2e/instruments.spec.ts +1197 -9
- package/template/apps/web/package.json +4 -2
- package/template/apps/web/scripts/check.mjs +2 -2
- package/template/apps/web/scripts/verify-build.mjs +14 -2
- package/template/apps/web/src/AuthoritativeSelector.tsx +371 -0
- package/template/apps/web/src/CollegePage.tsx +181 -0
- package/template/apps/web/src/FilePreviewPage.tsx +303 -0
- package/template/apps/web/src/InstrumentDetailPage.tsx +94 -20
- package/template/apps/web/src/InstrumentForm.tsx +347 -94
- package/template/apps/web/src/InstrumentFormPage.tsx +49 -9
- package/template/apps/web/src/InstrumentListPage.tsx +30 -35
- package/template/apps/web/src/Shell.tsx +229 -41
- package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +226 -0
- package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +698 -0
- package/template/apps/web/src/data-provider.ts +1 -1
- package/template/apps/web/src/fields.ts +17 -6
- package/template/apps/web/src/instrument.ts +1 -1
- package/template/apps/web/src/main.tsx +21 -1
- package/template/apps/web/src/platform-client.ts +248 -2
- package/template/apps/web/src/runtime-meta.ts +15 -0
- package/template/apps/web/src/runtime.tsx +4 -2
- package/template/apps/web/src/styles.css +1007 -28
- package/template/apps/web/test/contracts.test.ts +473 -10
- package/template/openxiangda.config.ts +24 -5
- package/template/package.json +3 -3
- package/template/packages/contracts/src/generated.ts +22 -0
- package/template/platform/data/colleges.ts +21 -0
- package/template/platform/data/instruments.ts +1 -1
- package/template/scripts/verify-template-budget.mjs +2 -2
|
@@ -1,4 +1,58 @@
|
|
|
1
|
-
import {
|
|
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
|
+
import * as XLSX from 'xlsx';
|
|
12
|
+
|
|
13
|
+
const declaredInstrumentFields = new Set(
|
|
14
|
+
instrumentFields.filter(field => !field.system).map(field => field.key)
|
|
15
|
+
);
|
|
16
|
+
const collegeId = '11111111-1111-4111-8111-111111111111';
|
|
17
|
+
const userId = '22222222-2222-4222-8222-222222222222';
|
|
18
|
+
const departmentId = '33333333-3333-4333-8333-333333333333';
|
|
19
|
+
const minimalDocx = Buffer.from(
|
|
20
|
+
'UEsDBAoAAAAIABJvFV15bjPX6AAAAK0BAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbH1QyU7DMBD9FWuuKHHggBCK0wPLETiUDxjZk8SqN3nc0v49Tlt6QIXjzFv1+tXeO7GjzDYGBbdtB4KCjsaGScHn+rV5AMEFg0EXAyk4EMNq6NeHRCyqNrCCuZT0KCXrmTxyGxOFiowxeyz1zJNMqDc4kbzrunupYygUSlMWDxj6Zxpx64p42df3qUcmxyCeTsQlSwGm5KzGUnG5C+ZXSnNOaKvyyOHZJr6pBJBXExbk74Cz7r0Ok60h8YG5vKGvLPkVs5Em6q2vyvZ/mys94zhaTRf94pZy1MRcF/euvSAebfjpL49zD99QSwMECgAAAAAAEm8VXQAAAAAAAAAAAAAAAAYAAABfcmVscy9QSwMECgAAAAgAEm8VXZv9N+qtAAAAKQEAAAsAAABfcmVscy8ucmVsc43POw7CMAwG4KtE3mlaBoRQ0y4IqSsqB7ASN61oHkrCo7cnAwNFDIy2f3+W6/ZpZnanECdnBVRFCYysdGqyWsClP232wGJCq3B2lgQsFKFt6jPNmPJKHCcfWTZsFDCm5A+cRzmSwVg4TzZPBhcMplwGzT3KK2ri27Lc8fBpwNpknRIQOlUB6xdP/9huGCZJRydvhmz6ceIrkWUMmpKAhwuKq3e7yCzwpuarF5sXUEsDBAoAAAAAABJvFV0AAAAAAAAAAAAAAAAFAAAAd29yZC9QSwMECgAAAAgAEm8VXZ1FWubkAAAAWwEAABEAAAB3b3JkL2RvY3VtZW50LnhtbEVQwW7DIAz9FcR9JY26qYqS9NbbtErbtF1pcBOkgBF4Zd3Xz6SKenn2s/2eDe3h183iCjFZ9J3cbiopwA9orB87+flxfNpLkUh7o2f00MkbJHno29wYHH4ceBJs4FOTOzkRhUapNEzgdNpgAM+9C0aniWkcVcZoQsQBUmJ/N6u6ql6U09bLYnlGcysxFIgFqH9jl2+r/Wi0+GK5OEW4WsitKt2CccFFk2Cg0yIM4/ufyOWqbV3v+FG5mTh/3nOu7gOvOnKVMHB9dx+JdpzoQc9IhO7BZ7isXbUsXfep9Xb1+Jf+H1BLAQIUAAoAAAAIABJvFV15bjPX6AAAAK0BAAATAAAAAAAAAAAAAAAAAAAAAABbQ29udGVudF9UeXBlc10ueG1sUEsBAhQACgAAAAAAEm8VXQAAAAAAAAAAAAAAAAYAAAAAAAAAAAAQAAAAGQEAAF9yZWxzL1BLAQIUAAoAAAAIABJvFV2b/TfqrQAAACkBAAALAAAAAAAAAAAAAAAAAD0BAABfcmVscy8ucmVsc1BLAQIUAAoAAAAAABJvFV0AAAAAAAAAAAAAAAAFAAAAAAAAAAAAEAAAABMCAAB3b3JkL1BLAQIUAAoAAAAIABJvFV2dRVrm5AAAAFsBAAARAAAAAAAAAAAAAAAAADYCAAB3b3JkL2RvY3VtZW50LnhtbFBLBQYAAAAABQAFACABAABJAwAAAAA=',
|
|
21
|
+
'base64'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
function previewWorkbook() {
|
|
25
|
+
const workbook = XLSX.utils.book_new();
|
|
26
|
+
XLSX.utils.book_append_sheet(
|
|
27
|
+
workbook,
|
|
28
|
+
XLSX.utils.aoa_to_sheet([
|
|
29
|
+
['仪器编码', '仪器名称'],
|
|
30
|
+
['INS-PREVIEW-001', '透射电子显微镜'],
|
|
31
|
+
]),
|
|
32
|
+
'仪器清单'
|
|
33
|
+
);
|
|
34
|
+
XLSX.utils.book_append_sheet(
|
|
35
|
+
workbook,
|
|
36
|
+
XLSX.utils.aoa_to_sheet([['第二个工作表'], ['预览正常']]),
|
|
37
|
+
'维护记录'
|
|
38
|
+
);
|
|
39
|
+
return XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function expectDeclaredInstrumentQuery(request: Request) {
|
|
43
|
+
const body = request.postDataJSON() as {
|
|
44
|
+
filters?: Array<{ field: string }>;
|
|
45
|
+
order?: Array<{ field: string }>;
|
|
46
|
+
};
|
|
47
|
+
const fields = [
|
|
48
|
+
...(body.filters || []).map(filter => filter.field),
|
|
49
|
+
...(body.order || []).map(order => order.field),
|
|
50
|
+
];
|
|
51
|
+
expect(fields.every(field => declaredInstrumentFields.has(field))).toBe(true);
|
|
52
|
+
expect(fields).not.toContain('updatedAt');
|
|
53
|
+
expect(fields).not.toContain('updated_at');
|
|
54
|
+
return body;
|
|
55
|
+
}
|
|
2
56
|
|
|
3
57
|
test('renders the production warning and never requests a role session', async ({
|
|
4
58
|
page,
|
|
@@ -44,7 +98,7 @@ test('renders the production warning and never requests a role session', async (
|
|
|
44
98
|
},
|
|
45
99
|
principal: {
|
|
46
100
|
type: 'user',
|
|
47
|
-
userId
|
|
101
|
+
userId,
|
|
48
102
|
roleCodes: ['college_admin', 'instrument_admin'],
|
|
49
103
|
capabilityCodes: [
|
|
50
104
|
'app:instrument-center:data:instruments:read',
|
|
@@ -59,9 +113,11 @@ test('renders the production warning and never requests a role session', async (
|
|
|
59
113
|
return;
|
|
60
114
|
}
|
|
61
115
|
if (url.pathname.endsWith('/native/data/instruments/query')) {
|
|
116
|
+
expectDeclaredInstrumentQuery(route.request());
|
|
62
117
|
expect(route.request().postDataJSON()).toMatchObject({
|
|
63
118
|
schemaVersion: 'openxiangda.data-query/v2',
|
|
64
119
|
environmentKey: 'production',
|
|
120
|
+
order: [{ field: 'instrumentCode', direction: 'asc' }],
|
|
65
121
|
});
|
|
66
122
|
await route.fulfill({
|
|
67
123
|
contentType: 'application/json',
|
|
@@ -83,13 +139,13 @@ test('renders the production warning and never requests a role session', async (
|
|
|
83
139
|
usageStatus: 'active',
|
|
84
140
|
instrumentStatus: 'normal',
|
|
85
141
|
openToOutside: true,
|
|
86
|
-
collegeId
|
|
87
|
-
manageDepartmentId:
|
|
88
|
-
useDepartmentId:
|
|
89
|
-
instrumentAdminIds: [
|
|
90
|
-
contactUserIds: [
|
|
142
|
+
collegeId,
|
|
143
|
+
manageDepartmentId: departmentId,
|
|
144
|
+
useDepartmentId: departmentId,
|
|
145
|
+
instrumentAdminIds: [userId],
|
|
146
|
+
contactUserIds: [userId],
|
|
91
147
|
contactPhone: '13800138000',
|
|
92
|
-
|
|
148
|
+
updated_at: '2026-08-21T00:00:00.000Z',
|
|
93
149
|
},
|
|
94
150
|
],
|
|
95
151
|
total: 1,
|
|
@@ -100,6 +156,7 @@ test('renders the production warning and never requests a role session', async (
|
|
|
100
156
|
});
|
|
101
157
|
return;
|
|
102
158
|
}
|
|
159
|
+
if (await fulfillSelector(route)) return;
|
|
103
160
|
await route.fulfill({
|
|
104
161
|
status: 404,
|
|
105
162
|
contentType: 'application/json',
|
|
@@ -110,7 +167,10 @@ test('renders the production warning and never requests a role session', async (
|
|
|
110
167
|
await expect(page.getByTestId('production-data-warning')).toBeVisible();
|
|
111
168
|
await expect(page.getByTestId('instrument-title')).toHaveText('仪器资源');
|
|
112
169
|
await expect(page.getByText('场发射扫描电子显微镜')).toBeVisible();
|
|
113
|
-
await expect(
|
|
170
|
+
await expect(
|
|
171
|
+
page.getByRole('complementary').getByText('仪器管理', { exact: true })
|
|
172
|
+
).toBeVisible();
|
|
173
|
+
await expect(page.getByText('王老师', { exact: true }).first()).toBeVisible();
|
|
114
174
|
expect(requests.filter(url => url.includes('role-session'))).toHaveLength(0);
|
|
115
175
|
expect(
|
|
116
176
|
requests.some(url =>
|
|
@@ -119,6 +179,548 @@ test('renders the production warning and never requests a role session', async (
|
|
|
119
179
|
).toBe(true);
|
|
120
180
|
});
|
|
121
181
|
|
|
182
|
+
test('continues create, list, detail, update, and delete with declared query fields', async ({
|
|
183
|
+
page,
|
|
184
|
+
}) => {
|
|
185
|
+
test.setTimeout(60_000);
|
|
186
|
+
let record: Record<string, unknown> | undefined;
|
|
187
|
+
const queryBodies: unknown[] = [];
|
|
188
|
+
const operations: string[] = [];
|
|
189
|
+
const protectedCapabilities = instrumentFields.flatMap(field => [
|
|
190
|
+
...(field.createCapability ? [field.createCapability] : []),
|
|
191
|
+
...(field.updateCapability ? [field.updateCapability] : []),
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
await installProductionRuntimeMeta(page);
|
|
195
|
+
await page.route('**/mock-upload/instrument-file-1', route =>
|
|
196
|
+
route.fulfill({ status: 200, body: '' })
|
|
197
|
+
);
|
|
198
|
+
await page.route('**/service/**', async route => {
|
|
199
|
+
const request = route.request();
|
|
200
|
+
const url = new URL(request.url());
|
|
201
|
+
const path = url.pathname;
|
|
202
|
+
if (
|
|
203
|
+
path.endsWith('/native/current-user') &&
|
|
204
|
+
url.searchParams.get('environmentKey') === 'production'
|
|
205
|
+
) {
|
|
206
|
+
await route.fulfill({
|
|
207
|
+
contentType: 'application/json',
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
code: 200,
|
|
210
|
+
data: {
|
|
211
|
+
schemaVersion: 'openxiangda.current-user/v2',
|
|
212
|
+
appCode: 'instrument-center',
|
|
213
|
+
environment: {
|
|
214
|
+
id: 'production-id',
|
|
215
|
+
key: 'production',
|
|
216
|
+
activeAppVersionId: 'app-version-1',
|
|
217
|
+
headRevision: 7,
|
|
218
|
+
authzRevisionId: 'authz-revision-1',
|
|
219
|
+
authzVersion: 3,
|
|
220
|
+
scopeDataVersion: 'scope-data-version-1',
|
|
221
|
+
},
|
|
222
|
+
principal: {
|
|
223
|
+
type: 'user',
|
|
224
|
+
userId,
|
|
225
|
+
roleCodes: ['school_admin'],
|
|
226
|
+
capabilityCodes: [
|
|
227
|
+
'app:instrument-center:data:instruments:read',
|
|
228
|
+
'app:instrument-center:data:instruments:create',
|
|
229
|
+
'app:instrument-center:data:instruments:update',
|
|
230
|
+
'app:instrument-center:data:instruments:delete',
|
|
231
|
+
...protectedCapabilities,
|
|
232
|
+
],
|
|
233
|
+
isAppSuperAdmin: false,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
}),
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (await fulfillSelector(route)) return;
|
|
241
|
+
if (path.endsWith('/native/data/instruments/query')) {
|
|
242
|
+
queryBodies.push(expectDeclaredInstrumentQuery(request));
|
|
243
|
+
operations.push('query');
|
|
244
|
+
await route.fulfill({
|
|
245
|
+
contentType: 'application/json',
|
|
246
|
+
body: JSON.stringify({
|
|
247
|
+
code: 200,
|
|
248
|
+
data: {
|
|
249
|
+
schemaVersion: 'openxiangda.data-page/v2',
|
|
250
|
+
resourceCode: 'instruments',
|
|
251
|
+
items: record ? [record] : [],
|
|
252
|
+
total: record ? 1 : 0,
|
|
253
|
+
limit: 20,
|
|
254
|
+
offset: 0,
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (path.endsWith('/files/uploads/initiate')) {
|
|
261
|
+
const body = request.postDataJSON() as {
|
|
262
|
+
fieldCode: string;
|
|
263
|
+
fileName: string;
|
|
264
|
+
fileSize: number;
|
|
265
|
+
};
|
|
266
|
+
expect(body).toMatchObject({
|
|
267
|
+
fieldCode: 'attachments',
|
|
268
|
+
fileName: '设备说明书.pdf',
|
|
269
|
+
});
|
|
270
|
+
operations.push('upload-initiate');
|
|
271
|
+
await route.fulfill({
|
|
272
|
+
contentType: 'application/json',
|
|
273
|
+
body: JSON.stringify({
|
|
274
|
+
code: 200,
|
|
275
|
+
data: {
|
|
276
|
+
schemaVersion: 'openxiangda.data-file-upload-plan/v2',
|
|
277
|
+
resourceCode: 'instruments',
|
|
278
|
+
fieldCode: 'attachments',
|
|
279
|
+
file: {
|
|
280
|
+
schemaVersion: 'openxiangda.data-file-ref/v2',
|
|
281
|
+
id: 'instrument-file-1',
|
|
282
|
+
name: body.fileName,
|
|
283
|
+
size: body.fileSize,
|
|
284
|
+
contentType: 'application/pdf',
|
|
285
|
+
},
|
|
286
|
+
uploadMethod: 'PUT',
|
|
287
|
+
uploadUrl: `${url.origin}/mock-upload/instrument-file-1`,
|
|
288
|
+
headers: {},
|
|
289
|
+
expiresAt: '2026-08-21T18:00:00.000Z',
|
|
290
|
+
},
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (path.endsWith('/files/instrument-file-1/complete')) {
|
|
296
|
+
operations.push('upload-complete');
|
|
297
|
+
await route.fulfill({
|
|
298
|
+
contentType: 'application/json',
|
|
299
|
+
body: JSON.stringify({
|
|
300
|
+
code: 200,
|
|
301
|
+
data: {
|
|
302
|
+
schemaVersion: 'openxiangda.data-file-ref/v2',
|
|
303
|
+
id: 'instrument-file-1',
|
|
304
|
+
name: '设备说明书.pdf',
|
|
305
|
+
size: 2048,
|
|
306
|
+
contentType: 'application/pdf',
|
|
307
|
+
},
|
|
308
|
+
}),
|
|
309
|
+
});
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (path.endsWith('/native/data/instruments/records')) {
|
|
313
|
+
const body = request.postDataJSON() as {
|
|
314
|
+
environmentKey: string;
|
|
315
|
+
data: Record<string, unknown>;
|
|
316
|
+
};
|
|
317
|
+
expect(body.environmentKey).toBe('production');
|
|
318
|
+
expect(body.data.attachments).toEqual([
|
|
319
|
+
{
|
|
320
|
+
schemaVersion: 'openxiangda.data-file-ref/v2',
|
|
321
|
+
id: 'instrument-file-1',
|
|
322
|
+
name: '设备说明书.pdf',
|
|
323
|
+
size: 2048,
|
|
324
|
+
contentType: 'application/pdf',
|
|
325
|
+
},
|
|
326
|
+
]);
|
|
327
|
+
record = {
|
|
328
|
+
...body.data,
|
|
329
|
+
id: '092b0000-0000-4000-8000-000000000001',
|
|
330
|
+
revision: 1,
|
|
331
|
+
updated_at: '2026-08-21T01:00:00.000Z',
|
|
332
|
+
};
|
|
333
|
+
operations.push('create');
|
|
334
|
+
await fulfillRecord(route, record);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (path.endsWith('/update')) {
|
|
338
|
+
const body = request.postDataJSON() as {
|
|
339
|
+
environmentKey: string;
|
|
340
|
+
expectedRevision: number;
|
|
341
|
+
data: Record<string, unknown>;
|
|
342
|
+
};
|
|
343
|
+
expect(body.environmentKey).toBe('production');
|
|
344
|
+
expect(body.expectedRevision).toBe(record?.revision);
|
|
345
|
+
record = {
|
|
346
|
+
...record,
|
|
347
|
+
...body.data,
|
|
348
|
+
revision: Number(record?.revision) + 1,
|
|
349
|
+
updated_at: '2026-08-21T02:00:00.000Z',
|
|
350
|
+
};
|
|
351
|
+
operations.push('update');
|
|
352
|
+
await fulfillRecord(route, record);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (path.endsWith('/delete')) {
|
|
356
|
+
const deleted = record!;
|
|
357
|
+
const body = request.postDataJSON() as {
|
|
358
|
+
environmentKey: string;
|
|
359
|
+
expectedRevision: number;
|
|
360
|
+
};
|
|
361
|
+
expect(body.environmentKey).toBe('production');
|
|
362
|
+
expect(body.expectedRevision).toBe(record?.revision);
|
|
363
|
+
record = undefined;
|
|
364
|
+
operations.push('delete');
|
|
365
|
+
await fulfillRecord(route, deleted);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (path.endsWith('/audit')) {
|
|
369
|
+
await route.fulfill({
|
|
370
|
+
contentType: 'application/json',
|
|
371
|
+
body: JSON.stringify({
|
|
372
|
+
code: 200,
|
|
373
|
+
data: {
|
|
374
|
+
schemaVersion: 'openxiangda.data-audit-page/v2',
|
|
375
|
+
resourceCode: 'instruments',
|
|
376
|
+
recordId: record?.id,
|
|
377
|
+
items: [],
|
|
378
|
+
total: 0,
|
|
379
|
+
limit: 10,
|
|
380
|
+
offset: 0,
|
|
381
|
+
},
|
|
382
|
+
}),
|
|
383
|
+
});
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (path.includes('/native/data/instruments/records/')) {
|
|
387
|
+
operations.push('get');
|
|
388
|
+
if (!record) {
|
|
389
|
+
await route.fulfill({
|
|
390
|
+
status: 404,
|
|
391
|
+
contentType: 'application/json',
|
|
392
|
+
body: JSON.stringify({ code: 404, message: 'not found' }),
|
|
393
|
+
});
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
await fulfillRecord(route, record);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
await route.fulfill({
|
|
400
|
+
status: 404,
|
|
401
|
+
contentType: 'application/json',
|
|
402
|
+
body: JSON.stringify({ code: 404, message: 'not mocked' }),
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
await page.goto('/view/instrument-center/instruments/new');
|
|
407
|
+
await expect(
|
|
408
|
+
page.getByRole('heading', { name: '新增仪器', level: 2 })
|
|
409
|
+
).toBeVisible();
|
|
410
|
+
await expect(page.getByText('管理与联系')).toBeVisible();
|
|
411
|
+
await expect(page.getByText('地址与资料')).toBeVisible();
|
|
412
|
+
await page.getByLabel('仪器编码').fill('INS-LIVE-001');
|
|
413
|
+
await page.getByLabel('仪器中文名').fill('原位电子显微镜');
|
|
414
|
+
await page.getByLabel('规格型号').fill('EM-LIVE');
|
|
415
|
+
await chooseOption(page, '仪器分类', '显微成像');
|
|
416
|
+
await page.getByLabel('资产编码').fill('ASSET-LIVE-001');
|
|
417
|
+
await page.getByLabel('启用日期').fill('2026-08-21');
|
|
418
|
+
await chooseOption(page, '所属学院', '先进材料学院');
|
|
419
|
+
await pickDirectoryDepartment(page, '管理部门', '材料测试中心');
|
|
420
|
+
await pickDirectoryDepartment(page, '使用部门', '材料测试中心');
|
|
421
|
+
await pickDirectoryMember(page, '仪器管理员', '王老师');
|
|
422
|
+
await pickDirectoryMember(page, '联系人', '王老师');
|
|
423
|
+
await page.getByLabel('联系电话').fill('13800138000');
|
|
424
|
+
await page
|
|
425
|
+
.locator('.oxa-file-field input[type="file"]')
|
|
426
|
+
.last()
|
|
427
|
+
.setInputFiles({
|
|
428
|
+
name: '设备说明书.pdf',
|
|
429
|
+
mimeType: 'application/pdf',
|
|
430
|
+
buffer: Buffer.from('openxiangda instrument manual'),
|
|
431
|
+
});
|
|
432
|
+
await expect(page.getByText('设备说明书.pdf')).toBeVisible();
|
|
433
|
+
await page.getByTestId('save-instrument').click();
|
|
434
|
+
|
|
435
|
+
await expect(page).toHaveURL(/\/view\/instrument-center\/instruments$/);
|
|
436
|
+
await expect(page.getByText('原位电子显微镜')).toBeVisible();
|
|
437
|
+
await expect(page.locator('.ant-table-tbody')).toContainText('2026');
|
|
438
|
+
await expect(
|
|
439
|
+
page
|
|
440
|
+
.getByRole('columnheader', { name: '更新时间' })
|
|
441
|
+
.locator('.ant-table-column-sorter')
|
|
442
|
+
).toHaveCount(0);
|
|
443
|
+
const queriesBeforeSort = queryBodies.length;
|
|
444
|
+
await page.getByRole('columnheader', { name: /仪器名称/ }).click();
|
|
445
|
+
await expect.poll(() => queryBodies.length).toBeGreaterThan(queriesBeforeSort);
|
|
446
|
+
expect(
|
|
447
|
+
(queryBodies.at(-1) as { order: Array<{ field: string; direction: string }> })
|
|
448
|
+
.order
|
|
449
|
+
).toEqual([{ field: 'chineseName', direction: 'asc' }]);
|
|
450
|
+
await page.getByText('原位电子显微镜').click();
|
|
451
|
+
await expect(
|
|
452
|
+
page.getByRole('heading', { name: '仪器详情', level: 2 })
|
|
453
|
+
).toBeVisible();
|
|
454
|
+
await expect(page.getByText('记录信息')).toBeVisible();
|
|
455
|
+
await expect(page.getByText('设备说明书.pdf')).toBeVisible();
|
|
456
|
+
await page.getByRole('button', { name: /编.*辑/ }).click();
|
|
457
|
+
await expect(page.getByRole('dialog')).toHaveCount(0);
|
|
458
|
+
await expect(page.getByText('管理与联系')).toBeVisible();
|
|
459
|
+
await page.getByLabel('仪器中文名').fill('原位电子显微镜(更新)');
|
|
460
|
+
await page.getByTestId('save-instrument').click();
|
|
461
|
+
await expect(
|
|
462
|
+
page.getByRole('heading', { name: '原位电子显微镜(更新)' })
|
|
463
|
+
).toBeVisible();
|
|
464
|
+
await page.getByRole('button', { name: '返回列表' }).click();
|
|
465
|
+
await expect(page).toHaveURL(/\/view\/instrument-center\/instruments$/);
|
|
466
|
+
await expect(
|
|
467
|
+
page.locator('.ant-table-tbody').getByText('原位电子显微镜(更新)')
|
|
468
|
+
).toBeVisible();
|
|
469
|
+
await page.getByLabel('删除').click();
|
|
470
|
+
await page.getByRole('button', { name: /确.*定/ }).click();
|
|
471
|
+
await expect(
|
|
472
|
+
page.locator('.ant-table-tbody').getByText('原位电子显微镜(更新)')
|
|
473
|
+
).toHaveCount(0);
|
|
474
|
+
|
|
475
|
+
await page.setViewportSize({ width: 760, height: 900 });
|
|
476
|
+
await page.goto('/view/instrument-center/instruments/new');
|
|
477
|
+
await expect(
|
|
478
|
+
page.getByRole('heading', { name: '新增仪器', level: 2 })
|
|
479
|
+
).toBeVisible();
|
|
480
|
+
await expect(page.getByRole('dialog', { name: '新增仪器' })).toHaveCount(0);
|
|
481
|
+
await expect(page.getByRole('button', { name: '展开侧边栏' })).toBeVisible();
|
|
482
|
+
|
|
483
|
+
expect(queryBodies.length).toBeGreaterThanOrEqual(2);
|
|
484
|
+
expect(operations).toEqual(
|
|
485
|
+
expect.arrayContaining([
|
|
486
|
+
'upload-initiate',
|
|
487
|
+
'upload-complete',
|
|
488
|
+
'create',
|
|
489
|
+
'query',
|
|
490
|
+
'get',
|
|
491
|
+
'update',
|
|
492
|
+
'delete',
|
|
493
|
+
])
|
|
494
|
+
);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('college admin can choose only granted college on create and cannot reassign on update', async ({
|
|
498
|
+
page,
|
|
499
|
+
}) => {
|
|
500
|
+
const requests: string[] = [];
|
|
501
|
+
page.on('request', request => requests.push(request.url()));
|
|
502
|
+
const protectedCreate = instrumentFields.flatMap(field =>
|
|
503
|
+
field.createCapability ? [field.createCapability] : []
|
|
504
|
+
);
|
|
505
|
+
const protectedUpdate = instrumentFields.flatMap(field =>
|
|
506
|
+
field.updateCapability && field.key !== 'collegeId'
|
|
507
|
+
? [field.updateCapability]
|
|
508
|
+
: []
|
|
509
|
+
);
|
|
510
|
+
await installProductionRuntimeMeta(page);
|
|
511
|
+
await page.route('**/service/**', async route => {
|
|
512
|
+
const url = new URL(route.request().url());
|
|
513
|
+
if (url.pathname.endsWith('/native/current-user')) {
|
|
514
|
+
await fulfillIdentity(route, 'college_admin', [
|
|
515
|
+
'app:instrument-center:data:instruments:read',
|
|
516
|
+
'app:instrument-center:data:instruments:create',
|
|
517
|
+
'app:instrument-center:data:instruments:update',
|
|
518
|
+
...protectedCreate,
|
|
519
|
+
...protectedUpdate,
|
|
520
|
+
]);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (await fulfillSelector(route)) return;
|
|
524
|
+
if (url.pathname.includes('/native/data/instruments/records/')) {
|
|
525
|
+
await fulfillRecord(route, instrumentFixture());
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
await route.fulfill({ status: 404, body: '{}' });
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
await page.goto('/view/instrument-center/instruments/new');
|
|
532
|
+
await expect(page.getByLabel('所属学院')).toBeEnabled();
|
|
533
|
+
await chooseOption(page, '所属学院', '先进材料学院');
|
|
534
|
+
await expect(page.getByLabel('所属学院')).toHaveValue('');
|
|
535
|
+
await expect(page.getByLabel('仪器管理员')).toBeEnabled();
|
|
536
|
+
|
|
537
|
+
await page.goto(
|
|
538
|
+
'/view/instrument-center/instruments/092b0000-0000-4000-8000-000000000001/edit'
|
|
539
|
+
);
|
|
540
|
+
await expect(page.getByLabel('所属学院')).toBeDisabled();
|
|
541
|
+
await expect(page.getByLabel('仪器管理员')).toBeEnabled();
|
|
542
|
+
expect(requests.filter(url => url.includes('role-session'))).toHaveLength(0);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test('instrument admin edits ordinary fields but every protected field stays disabled', async ({
|
|
546
|
+
page,
|
|
547
|
+
}) => {
|
|
548
|
+
const requests: string[] = [];
|
|
549
|
+
page.on('request', request => requests.push(request.url()));
|
|
550
|
+
await installProductionRuntimeMeta(page);
|
|
551
|
+
await page.route('**/service/**', async route => {
|
|
552
|
+
const url = new URL(route.request().url());
|
|
553
|
+
if (url.pathname.endsWith('/native/current-user')) {
|
|
554
|
+
await fulfillIdentity(route, 'instrument_admin', [
|
|
555
|
+
'app:instrument-center:data:instruments:read',
|
|
556
|
+
'app:instrument-center:data:instruments:update',
|
|
557
|
+
]);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (await fulfillSelector(route)) return;
|
|
561
|
+
if (url.pathname.includes('/native/data/instruments/records/')) {
|
|
562
|
+
await fulfillRecord(route, instrumentFixture());
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
await route.fulfill({ status: 404, body: '{}' });
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
await page.goto(
|
|
569
|
+
'/view/instrument-center/instruments/092b0000-0000-4000-8000-000000000001/edit'
|
|
570
|
+
);
|
|
571
|
+
for (const label of [
|
|
572
|
+
'仪器编码',
|
|
573
|
+
'资产编码',
|
|
574
|
+
'资产价值(元)',
|
|
575
|
+
'所属学院',
|
|
576
|
+
'仪器管理员',
|
|
577
|
+
]) {
|
|
578
|
+
await expect(page.getByLabel(label)).toBeDisabled();
|
|
579
|
+
}
|
|
580
|
+
await expect(page.getByLabel('仪器中文名')).toBeEnabled();
|
|
581
|
+
expect(requests.filter(url => url.includes('role-session'))).toHaveLength(0);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test('directory selector debounces search and keeps empty or failed results honest', async ({
|
|
585
|
+
page,
|
|
586
|
+
}) => {
|
|
587
|
+
const keywords: string[] = [];
|
|
588
|
+
await installProductionRuntimeMeta(page);
|
|
589
|
+
await page.route('**/service/**', async route => {
|
|
590
|
+
const url = new URL(route.request().url());
|
|
591
|
+
if (url.pathname.endsWith('/native/current-user')) {
|
|
592
|
+
await fulfillIdentity(route, 'school_admin', [
|
|
593
|
+
'app:instrument-center:data:instruments:read',
|
|
594
|
+
'app:instrument-center:data:instruments:create',
|
|
595
|
+
...instrumentFields.flatMap(field =>
|
|
596
|
+
field.createCapability ? [field.createCapability] : []
|
|
597
|
+
),
|
|
598
|
+
]);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (url.pathname.endsWith('/directory/users')) {
|
|
602
|
+
const keyword = url.searchParams.get('keyword') || '';
|
|
603
|
+
keywords.push(keyword);
|
|
604
|
+
if (keyword === '错误词') {
|
|
605
|
+
await route.fulfill({
|
|
606
|
+
status: 503,
|
|
607
|
+
contentType: 'application/json',
|
|
608
|
+
body: JSON.stringify({
|
|
609
|
+
code: 503,
|
|
610
|
+
errorCode: 'DIRECTORY_UNAVAILABLE',
|
|
611
|
+
message: '目录暂不可用',
|
|
612
|
+
}),
|
|
613
|
+
});
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
await route.fulfill({
|
|
617
|
+
contentType: 'application/json',
|
|
618
|
+
body: JSON.stringify({
|
|
619
|
+
code: 200,
|
|
620
|
+
data: directoryPage('user', []),
|
|
621
|
+
}),
|
|
622
|
+
});
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (await fulfillSelector(route)) return;
|
|
626
|
+
await route.fulfill({ status: 404, body: '{}' });
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
await page.goto('/view/instrument-center/instruments/new');
|
|
630
|
+
await page.getByRole('button', { name: /仪器管理员/ }).click();
|
|
631
|
+
const dialog = page.getByRole('dialog', { name: '选择成员' });
|
|
632
|
+
const input = dialog.getByPlaceholder('搜索成员姓名或工号');
|
|
633
|
+
await input.fill('王老师');
|
|
634
|
+
await input.fill('李老师');
|
|
635
|
+
await expect.poll(() => keywords).toEqual(['李老师']);
|
|
636
|
+
await expect(dialog.getByText('暂无成员')).toBeVisible();
|
|
637
|
+
await input.fill('错误词');
|
|
638
|
+
await expect.poll(() => keywords.at(-1)).toBe('错误词');
|
|
639
|
+
await expect(dialog.getByText(/DIRECTORY_UNAVAILABLE/)).toBeVisible();
|
|
640
|
+
await expect(dialog.getByText('王老师')).toHaveCount(0);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test('school admin maintains the app-owned college UUID catalog without delete', async ({
|
|
644
|
+
page,
|
|
645
|
+
}) => {
|
|
646
|
+
let colleges = [
|
|
647
|
+
{ id: collegeId, revision: 1, name: '先进材料学院', enabled: true },
|
|
648
|
+
];
|
|
649
|
+
const operations: string[] = [];
|
|
650
|
+
await installProductionRuntimeMeta(page);
|
|
651
|
+
await page.route('**/service/**', async route => {
|
|
652
|
+
const request = route.request();
|
|
653
|
+
const url = new URL(request.url());
|
|
654
|
+
if (url.pathname.endsWith('/native/current-user')) {
|
|
655
|
+
await fulfillIdentity(route, 'school_admin', [
|
|
656
|
+
'app:instrument-center:data:colleges:read',
|
|
657
|
+
'app:instrument-center:data:colleges:create',
|
|
658
|
+
'app:instrument-center:data:colleges:update',
|
|
659
|
+
]);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (url.pathname.endsWith('/native/data/colleges/query')) {
|
|
663
|
+
operations.push('query');
|
|
664
|
+
await route.fulfill({
|
|
665
|
+
contentType: 'application/json',
|
|
666
|
+
body: JSON.stringify({
|
|
667
|
+
code: 200,
|
|
668
|
+
data: {
|
|
669
|
+
schemaVersion: 'openxiangda.data-page/v2',
|
|
670
|
+
resourceCode: 'colleges',
|
|
671
|
+
items: colleges,
|
|
672
|
+
total: colleges.length,
|
|
673
|
+
limit: 20,
|
|
674
|
+
offset: 0,
|
|
675
|
+
},
|
|
676
|
+
}),
|
|
677
|
+
});
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (url.pathname.endsWith(`/native/data/colleges/records/${collegeId}/update`)) {
|
|
681
|
+
const body = request.postDataJSON() as {
|
|
682
|
+
expectedRevision: number;
|
|
683
|
+
data: { name: string; enabled: boolean };
|
|
684
|
+
};
|
|
685
|
+
expect(body.expectedRevision).toBe(1);
|
|
686
|
+
colleges = [{ ...colleges[0]!, ...body.data, revision: 2 }];
|
|
687
|
+
operations.push('update');
|
|
688
|
+
await fulfillCollegeRecord(route, colleges[0]!);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (url.pathname.endsWith('/native/data/colleges/records')) {
|
|
692
|
+
const body = request.postDataJSON() as {
|
|
693
|
+
data: { name: string; enabled: boolean };
|
|
694
|
+
};
|
|
695
|
+
const created = {
|
|
696
|
+
id: '44444444-4444-4444-8444-444444444444',
|
|
697
|
+
revision: 1,
|
|
698
|
+
...body.data,
|
|
699
|
+
};
|
|
700
|
+
colleges = [...colleges, created];
|
|
701
|
+
operations.push('create');
|
|
702
|
+
await fulfillCollegeRecord(route, created);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
await route.fulfill({ status: 404, body: '{}' });
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
await page.goto('/view/instrument-center/colleges');
|
|
709
|
+
await expect(page.getByText('先进材料学院')).toBeVisible();
|
|
710
|
+
await page.getByRole('button', { name: /编.*辑/ }).click();
|
|
711
|
+
await page.getByLabel('学院名称').fill('材料科学学院');
|
|
712
|
+
await page.getByRole('button', { name: /保.*存/ }).click();
|
|
713
|
+
await expect(page.getByText('材料科学学院')).toBeVisible();
|
|
714
|
+
await page.getByRole('button', { name: /新.*增学院/ }).click();
|
|
715
|
+
await page.getByLabel('学院名称').fill('生命科学学院');
|
|
716
|
+
await page.getByRole('button', { name: /保.*存/ }).click();
|
|
717
|
+
await expect(page.getByText('生命科学学院')).toBeVisible();
|
|
718
|
+
await expect(page.getByRole('button', { name: /删.*除/ })).toHaveCount(0);
|
|
719
|
+
expect(operations).toEqual(
|
|
720
|
+
expect.arrayContaining(['query', 'update', 'create'])
|
|
721
|
+
);
|
|
722
|
+
});
|
|
723
|
+
|
|
122
724
|
test('keeps the production warning visible when current-user fails closed', async ({
|
|
123
725
|
page,
|
|
124
726
|
}) => {
|
|
@@ -148,3 +750,589 @@ test('keeps the production warning visible when current-user fails closed', asyn
|
|
|
148
750
|
await expect(page.getByTestId('production-data-warning')).toBeVisible();
|
|
149
751
|
await expect(page.getByText('无法读取平台当前用户')).toBeVisible();
|
|
150
752
|
});
|
|
753
|
+
|
|
754
|
+
test('previews images in a lightbox and documents in focused new windows', async ({
|
|
755
|
+
page,
|
|
756
|
+
context,
|
|
757
|
+
}) => {
|
|
758
|
+
test.setTimeout(60_000);
|
|
759
|
+
const image = Buffer.from(
|
|
760
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
|
761
|
+
'base64'
|
|
762
|
+
);
|
|
763
|
+
const spreadsheet = previewWorkbook();
|
|
764
|
+
const pdf = Buffer.from(
|
|
765
|
+
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 200]>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF'
|
|
766
|
+
);
|
|
767
|
+
const files = new Map([
|
|
768
|
+
[
|
|
769
|
+
'preview-image',
|
|
770
|
+
{
|
|
771
|
+
name: '显微镜照片.png',
|
|
772
|
+
type: 'image/png',
|
|
773
|
+
body: image,
|
|
774
|
+
previewType: 'image',
|
|
775
|
+
renderMode: 'inline',
|
|
776
|
+
canPreview: true,
|
|
777
|
+
unsupportedReason: '',
|
|
778
|
+
},
|
|
779
|
+
],
|
|
780
|
+
[
|
|
781
|
+
'preview-docx',
|
|
782
|
+
{
|
|
783
|
+
name: '设备说明书.docx',
|
|
784
|
+
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
785
|
+
body: minimalDocx,
|
|
786
|
+
previewType: 'office',
|
|
787
|
+
renderMode: 'docx-html',
|
|
788
|
+
canPreview: true,
|
|
789
|
+
unsupportedReason: '',
|
|
790
|
+
},
|
|
791
|
+
],
|
|
792
|
+
[
|
|
793
|
+
'preview-xlsx',
|
|
794
|
+
{
|
|
795
|
+
name: '仪器清单.xlsx',
|
|
796
|
+
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
797
|
+
body: spreadsheet,
|
|
798
|
+
previewType: 'spreadsheet',
|
|
799
|
+
renderMode: 'excel-client',
|
|
800
|
+
canPreview: true,
|
|
801
|
+
unsupportedReason: '',
|
|
802
|
+
},
|
|
803
|
+
],
|
|
804
|
+
[
|
|
805
|
+
'preview-pdf',
|
|
806
|
+
{
|
|
807
|
+
name: '验收报告.pdf',
|
|
808
|
+
type: 'application/pdf',
|
|
809
|
+
body: pdf,
|
|
810
|
+
previewType: 'pdf',
|
|
811
|
+
renderMode: 'pdfjs',
|
|
812
|
+
canPreview: true,
|
|
813
|
+
unsupportedReason: '',
|
|
814
|
+
},
|
|
815
|
+
],
|
|
816
|
+
[
|
|
817
|
+
'preview-doc',
|
|
818
|
+
{
|
|
819
|
+
name: '旧版说明书.doc',
|
|
820
|
+
type: 'application/msword',
|
|
821
|
+
body: Buffer.from('legacy-word-file'),
|
|
822
|
+
previewType: 'download',
|
|
823
|
+
renderMode: 'download',
|
|
824
|
+
canPreview: false,
|
|
825
|
+
unsupportedReason: '该旧版 Office 格式暂不支持在线预览,请下载后打开',
|
|
826
|
+
},
|
|
827
|
+
],
|
|
828
|
+
]);
|
|
829
|
+
const byName = new Map([...files].map(([id, file]) => [file.name, id]));
|
|
830
|
+
const inlineReads: string[] = [];
|
|
831
|
+
await context.route('**/mock-upload/**', route =>
|
|
832
|
+
route.fulfill({ status: 200, body: '' })
|
|
833
|
+
);
|
|
834
|
+
await context.route('**/service/**', async route => {
|
|
835
|
+
const request = route.request();
|
|
836
|
+
const url = new URL(request.url());
|
|
837
|
+
if (url.pathname.endsWith('/dev-sessions/current')) {
|
|
838
|
+
await route.fulfill({
|
|
839
|
+
contentType: 'application/json',
|
|
840
|
+
body: JSON.stringify({
|
|
841
|
+
code: 200,
|
|
842
|
+
data: {
|
|
843
|
+
environment: { id: 'test-id', key: 'preproduction' },
|
|
844
|
+
principal: {
|
|
845
|
+
userId,
|
|
846
|
+
roleCodes: ['school_admin'],
|
|
847
|
+
capabilityCodes: [],
|
|
848
|
+
isAppSuperAdmin: true,
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
}),
|
|
852
|
+
});
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (url.pathname.endsWith('/files/uploads/initiate')) {
|
|
856
|
+
const input = request.postDataJSON() as {
|
|
857
|
+
fileName: string;
|
|
858
|
+
fileSize: number;
|
|
859
|
+
contentType: string;
|
|
860
|
+
};
|
|
861
|
+
const id = byName.get(input.fileName)!;
|
|
862
|
+
await route.fulfill({
|
|
863
|
+
contentType: 'application/json',
|
|
864
|
+
body: JSON.stringify({
|
|
865
|
+
code: 200,
|
|
866
|
+
data: {
|
|
867
|
+
schemaVersion: 'openxiangda.data-file-upload-plan/v2',
|
|
868
|
+
resourceCode: 'instruments',
|
|
869
|
+
fieldCode: input.contentType.startsWith('image/') ? 'image' : 'attachments',
|
|
870
|
+
file: {
|
|
871
|
+
schemaVersion: 'openxiangda.data-file-ref/v2',
|
|
872
|
+
id,
|
|
873
|
+
name: input.fileName,
|
|
874
|
+
size: input.fileSize,
|
|
875
|
+
contentType: input.contentType,
|
|
876
|
+
},
|
|
877
|
+
uploadMethod: 'PUT',
|
|
878
|
+
uploadUrl: `${url.origin}/mock-upload/${id}`,
|
|
879
|
+
headers: {},
|
|
880
|
+
expiresAt: '2026-08-21T18:00:00.000Z',
|
|
881
|
+
},
|
|
882
|
+
}),
|
|
883
|
+
});
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const complete = url.pathname.match(/\/files\/(preview-[^/]+)\/complete$/);
|
|
887
|
+
if (complete) {
|
|
888
|
+
const id = complete[1]!;
|
|
889
|
+
const file = files.get(id)!;
|
|
890
|
+
await route.fulfill({
|
|
891
|
+
contentType: 'application/json',
|
|
892
|
+
body: JSON.stringify({
|
|
893
|
+
code: 200,
|
|
894
|
+
data: {
|
|
895
|
+
schemaVersion: 'openxiangda.data-file-ref/v2',
|
|
896
|
+
id,
|
|
897
|
+
name: file.name,
|
|
898
|
+
size: file.body.length,
|
|
899
|
+
contentType: file.type,
|
|
900
|
+
},
|
|
901
|
+
}),
|
|
902
|
+
});
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const preview = url.pathname.match(/\/files\/(preview-[^/]+)\/preview$/);
|
|
906
|
+
if (preview) {
|
|
907
|
+
const id = preview[1]!;
|
|
908
|
+
const file = files.get(id)!;
|
|
909
|
+
await route.fulfill({
|
|
910
|
+
contentType: 'application/json',
|
|
911
|
+
body: JSON.stringify({
|
|
912
|
+
code: 200,
|
|
913
|
+
data: {
|
|
914
|
+
schemaVersion: 'openxiangda.data-file-preview/v2',
|
|
915
|
+
resourceCode: 'instruments',
|
|
916
|
+
file: {
|
|
917
|
+
schemaVersion: 'openxiangda.data-file-ref/v2',
|
|
918
|
+
id,
|
|
919
|
+
name: file.name,
|
|
920
|
+
size: file.body.length,
|
|
921
|
+
contentType: file.type,
|
|
922
|
+
},
|
|
923
|
+
extension: file.name.split('.').pop(),
|
|
924
|
+
previewType: file.previewType,
|
|
925
|
+
renderMode: file.renderMode,
|
|
926
|
+
previewSurface: file.previewType === 'image' ? 'media' : 'document',
|
|
927
|
+
previewProvider: 'browser',
|
|
928
|
+
canPreview: file.canPreview,
|
|
929
|
+
canDownload: true,
|
|
930
|
+
...(file.unsupportedReason
|
|
931
|
+
? { unsupportedReason: file.unsupportedReason }
|
|
932
|
+
: {}),
|
|
933
|
+
},
|
|
934
|
+
}),
|
|
935
|
+
});
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const content = url.pathname.match(/\/files\/(preview-[^/]+)\/content$/);
|
|
939
|
+
if (content) {
|
|
940
|
+
const id = content[1]!;
|
|
941
|
+
const file = files.get(id)!;
|
|
942
|
+
if (url.searchParams.get('disposition') === 'inline') inlineReads.push(id);
|
|
943
|
+
await route.fulfill({
|
|
944
|
+
status: 200,
|
|
945
|
+
contentType: file.type,
|
|
946
|
+
body: file.body,
|
|
947
|
+
});
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
await route.fulfill({ status: 404, body: '{}' });
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
await page.goto('/instruments/new');
|
|
954
|
+
const fileFields = page.locator('.oxa-file-field');
|
|
955
|
+
const imageInput = fileFields.nth(0).locator('input[type="file"]').first();
|
|
956
|
+
const attachmentInput = fileFields.nth(1).locator('input[type="file"]').first();
|
|
957
|
+
await imageInput.setInputFiles({
|
|
958
|
+
name: '显微镜照片.png',
|
|
959
|
+
mimeType: 'image/png',
|
|
960
|
+
buffer: image,
|
|
961
|
+
});
|
|
962
|
+
for (const [name, mimeType, buffer] of [
|
|
963
|
+
['设备说明书.docx', files.get('preview-docx')!.type, minimalDocx],
|
|
964
|
+
['仪器清单.xlsx', files.get('preview-xlsx')!.type, spreadsheet],
|
|
965
|
+
['验收报告.pdf', 'application/pdf', pdf],
|
|
966
|
+
['旧版说明书.doc', 'application/msword', files.get('preview-doc')!.body],
|
|
967
|
+
] as const) {
|
|
968
|
+
await attachmentInput.setInputFiles({ name, mimeType, buffer });
|
|
969
|
+
await expect(page.getByText(name, { exact: true })).toBeVisible();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
await page.getByRole('button', { name: '预览显微镜照片.png' }).click();
|
|
973
|
+
await expect(page.locator('.ant-image-preview-img')).toBeVisible();
|
|
974
|
+
await page.keyboard.press('Escape');
|
|
975
|
+
|
|
976
|
+
const docxPagePromise = context.waitForEvent('page');
|
|
977
|
+
await page.getByRole('button', { name: '预览设备说明书.docx' }).click();
|
|
978
|
+
const docxPage = await docxPagePromise;
|
|
979
|
+
await expect(docxPage.getByText('OpenXiangda Word Preview')).toBeVisible();
|
|
980
|
+
await docxPage.close();
|
|
981
|
+
|
|
982
|
+
const xlsxPagePromise = context.waitForEvent('page');
|
|
983
|
+
await page.getByRole('button', { name: '预览仪器清单.xlsx' }).click();
|
|
984
|
+
const xlsxPage = await xlsxPagePromise;
|
|
985
|
+
await expect(xlsxPage.getByText('透射电子显微镜')).toBeVisible();
|
|
986
|
+
await xlsxPage.getByText('维护记录', { exact: true }).click();
|
|
987
|
+
await expect(xlsxPage.getByText('预览正常')).toBeVisible();
|
|
988
|
+
await xlsxPage.close();
|
|
989
|
+
|
|
990
|
+
const pdfPagePromise = context.waitForEvent('page');
|
|
991
|
+
await page.getByRole('button', { name: '预览验收报告.pdf' }).click();
|
|
992
|
+
const pdfPage = await pdfPagePromise;
|
|
993
|
+
await expect(pdfPage.locator('iframe[title="验收报告.pdf PDF 预览"]')).toBeVisible();
|
|
994
|
+
await pdfPage.close();
|
|
995
|
+
|
|
996
|
+
const legacyPagePromise = context.waitForEvent('page');
|
|
997
|
+
await page.getByRole('button', { name: '预览旧版说明书.doc' }).click();
|
|
998
|
+
const legacyPage = await legacyPagePromise;
|
|
999
|
+
await expect(
|
|
1000
|
+
legacyPage.getByText('该旧版 Office 格式暂不支持在线预览,请下载后打开')
|
|
1001
|
+
).toBeVisible();
|
|
1002
|
+
await expect(legacyPage.getByRole('link', { name: '下载文件' })).toBeVisible();
|
|
1003
|
+
await legacyPage.close();
|
|
1004
|
+
|
|
1005
|
+
expect(inlineReads).toEqual(
|
|
1006
|
+
expect.arrayContaining([
|
|
1007
|
+
'preview-image',
|
|
1008
|
+
'preview-docx',
|
|
1009
|
+
'preview-xlsx',
|
|
1010
|
+
'preview-pdf',
|
|
1011
|
+
])
|
|
1012
|
+
);
|
|
1013
|
+
expect(inlineReads).not.toContain('preview-doc');
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
async function installProductionRuntimeMeta(page: Page) {
|
|
1017
|
+
await page.route('**/view/instrument-center/**', async route => {
|
|
1018
|
+
if (route.request().resourceType() !== 'document') {
|
|
1019
|
+
await route.continue();
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const response = await route.fetch();
|
|
1023
|
+
const body = (await response.text()).replace(
|
|
1024
|
+
'<head>',
|
|
1025
|
+
`<head>
|
|
1026
|
+
<meta name="openxiangda-runtime-base" content="/view/instrument-center/" />
|
|
1027
|
+
<meta name="openxiangda-app-code" content="instrument-center" />
|
|
1028
|
+
<meta name="openxiangda-environment" content="production" />`
|
|
1029
|
+
);
|
|
1030
|
+
await route.fulfill({ response, body });
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async function chooseOption(page: Page, label: string, option: string) {
|
|
1035
|
+
const input = page.getByLabel(label);
|
|
1036
|
+
await input.focus();
|
|
1037
|
+
await input.press('ArrowDown');
|
|
1038
|
+
await expect(
|
|
1039
|
+
page.getByRole('option', { name: option, exact: true })
|
|
1040
|
+
).toHaveCount(1);
|
|
1041
|
+
await input.press('Enter');
|
|
1042
|
+
await input.press('Escape');
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async function pickDirectoryMember(
|
|
1046
|
+
page: Page,
|
|
1047
|
+
label: string,
|
|
1048
|
+
option: string
|
|
1049
|
+
) {
|
|
1050
|
+
const trigger = page.getByRole('button', { name: new RegExp(label) });
|
|
1051
|
+
await trigger.click();
|
|
1052
|
+
const dialog = page.getByRole('dialog', { name: '选择成员' });
|
|
1053
|
+
await expect(dialog.getByText(option, { exact: true })).toBeVisible();
|
|
1054
|
+
await dialog.getByText(option, { exact: true }).click();
|
|
1055
|
+
await dialog.getByRole('button', { name: /确.*定/ }).click();
|
|
1056
|
+
await expect(trigger).toContainText(option);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
async function pickDirectoryDepartment(
|
|
1060
|
+
page: Page,
|
|
1061
|
+
label: string,
|
|
1062
|
+
option: string
|
|
1063
|
+
) {
|
|
1064
|
+
const trigger = page.getByRole('button', { name: new RegExp(label) });
|
|
1065
|
+
await trigger.click();
|
|
1066
|
+
const dialog = page.getByRole('dialog', { name: '选择部门' });
|
|
1067
|
+
await dialog.getByText(option, { exact: true }).click();
|
|
1068
|
+
await dialog.getByRole('button', { name: /确.*定/ }).click();
|
|
1069
|
+
await expect(trigger).toContainText(option);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
async function fulfillSelector(route: Route) {
|
|
1073
|
+
const request = route.request();
|
|
1074
|
+
const url = new URL(request.url());
|
|
1075
|
+
if (url.pathname.endsWith('/directory/departments/tree')) {
|
|
1076
|
+
await route.fulfill({
|
|
1077
|
+
contentType: 'application/json',
|
|
1078
|
+
body: JSON.stringify({
|
|
1079
|
+
code: 200,
|
|
1080
|
+
data: directoryPage('department', [
|
|
1081
|
+
{
|
|
1082
|
+
kind: 'department',
|
|
1083
|
+
id: departmentId,
|
|
1084
|
+
label: '材料测试中心',
|
|
1085
|
+
description: '先进材料学院 / 材料测试中心',
|
|
1086
|
+
path: [
|
|
1087
|
+
{ id: collegeId, label: '先进材料学院' },
|
|
1088
|
+
{ id: departmentId, label: '材料测试中心' },
|
|
1089
|
+
],
|
|
1090
|
+
hasChildren: false,
|
|
1091
|
+
selectable: true,
|
|
1092
|
+
},
|
|
1093
|
+
]),
|
|
1094
|
+
}),
|
|
1095
|
+
});
|
|
1096
|
+
return true;
|
|
1097
|
+
}
|
|
1098
|
+
if (
|
|
1099
|
+
url.pathname.endsWith(
|
|
1100
|
+
`/directory/departments/${encodeURIComponent(departmentId)}/users`
|
|
1101
|
+
)
|
|
1102
|
+
) {
|
|
1103
|
+
await route.fulfill({
|
|
1104
|
+
contentType: 'application/json',
|
|
1105
|
+
body: JSON.stringify({
|
|
1106
|
+
code: 200,
|
|
1107
|
+
data: directoryPage('user', [
|
|
1108
|
+
{
|
|
1109
|
+
kind: 'user',
|
|
1110
|
+
id: userId,
|
|
1111
|
+
label: '王老师',
|
|
1112
|
+
description: '工号 10001 · 材料测试中心',
|
|
1113
|
+
selectable: true,
|
|
1114
|
+
},
|
|
1115
|
+
]),
|
|
1116
|
+
}),
|
|
1117
|
+
});
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
if (url.pathname.endsWith('/directory/users')) {
|
|
1121
|
+
await route.fulfill({
|
|
1122
|
+
contentType: 'application/json',
|
|
1123
|
+
body: JSON.stringify({
|
|
1124
|
+
code: 200,
|
|
1125
|
+
data: directoryPage('user', [
|
|
1126
|
+
{ kind: 'user', id: userId, label: '王老师', selectable: true },
|
|
1127
|
+
]),
|
|
1128
|
+
}),
|
|
1129
|
+
});
|
|
1130
|
+
return true;
|
|
1131
|
+
}
|
|
1132
|
+
if (url.pathname.endsWith('/directory/departments')) {
|
|
1133
|
+
await route.fulfill({
|
|
1134
|
+
contentType: 'application/json',
|
|
1135
|
+
body: JSON.stringify({
|
|
1136
|
+
code: 200,
|
|
1137
|
+
data: directoryPage('department', [
|
|
1138
|
+
{
|
|
1139
|
+
kind: 'department',
|
|
1140
|
+
id: departmentId,
|
|
1141
|
+
label: '材料测试中心',
|
|
1142
|
+
selectable: true,
|
|
1143
|
+
},
|
|
1144
|
+
]),
|
|
1145
|
+
}),
|
|
1146
|
+
});
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
if (url.pathname.endsWith('/directory/resolve')) {
|
|
1150
|
+
const body = request.postDataJSON() as {
|
|
1151
|
+
kind: 'user' | 'department';
|
|
1152
|
+
ids: string[];
|
|
1153
|
+
};
|
|
1154
|
+
await route.fulfill({
|
|
1155
|
+
contentType: 'application/json',
|
|
1156
|
+
body: JSON.stringify({
|
|
1157
|
+
code: 200,
|
|
1158
|
+
data: directoryPage(
|
|
1159
|
+
body.kind,
|
|
1160
|
+
body.ids.map(id => ({
|
|
1161
|
+
kind: body.kind,
|
|
1162
|
+
id,
|
|
1163
|
+
label: body.kind === 'user' ? '王老师' : '材料测试中心',
|
|
1164
|
+
selectable: true,
|
|
1165
|
+
}))
|
|
1166
|
+
),
|
|
1167
|
+
}),
|
|
1168
|
+
});
|
|
1169
|
+
return true;
|
|
1170
|
+
}
|
|
1171
|
+
if (url.pathname.endsWith('/scope-values')) {
|
|
1172
|
+
await route.fulfill({
|
|
1173
|
+
contentType: 'application/json',
|
|
1174
|
+
body: JSON.stringify({
|
|
1175
|
+
code: 200,
|
|
1176
|
+
data: scopePage(url.searchParams.get('operation') || 'update', [
|
|
1177
|
+
{ value: collegeId, label: '先进材料学院', selectable: true },
|
|
1178
|
+
]),
|
|
1179
|
+
}),
|
|
1180
|
+
});
|
|
1181
|
+
return true;
|
|
1182
|
+
}
|
|
1183
|
+
if (url.pathname.endsWith('/scope-values/resolve')) {
|
|
1184
|
+
const body = request.postDataJSON() as {
|
|
1185
|
+
operation: string;
|
|
1186
|
+
values: string[];
|
|
1187
|
+
};
|
|
1188
|
+
await route.fulfill({
|
|
1189
|
+
contentType: 'application/json',
|
|
1190
|
+
body: JSON.stringify({
|
|
1191
|
+
code: 200,
|
|
1192
|
+
data: scopePage(
|
|
1193
|
+
body.operation,
|
|
1194
|
+
body.values.map(value => ({
|
|
1195
|
+
value,
|
|
1196
|
+
label: '先进材料学院',
|
|
1197
|
+
selectable: true,
|
|
1198
|
+
}))
|
|
1199
|
+
),
|
|
1200
|
+
}),
|
|
1201
|
+
});
|
|
1202
|
+
return true;
|
|
1203
|
+
}
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function directoryPage(
|
|
1208
|
+
kind: 'user' | 'department',
|
|
1209
|
+
items: Array<{
|
|
1210
|
+
kind: 'user' | 'department';
|
|
1211
|
+
id: string;
|
|
1212
|
+
label: string;
|
|
1213
|
+
description?: string;
|
|
1214
|
+
path?: Array<{ id: string; label: string }>;
|
|
1215
|
+
hasChildren?: boolean;
|
|
1216
|
+
selectable: boolean;
|
|
1217
|
+
}>
|
|
1218
|
+
) {
|
|
1219
|
+
return {
|
|
1220
|
+
schemaVersion: 'openxiangda.directory-entry-page/v2',
|
|
1221
|
+
kind,
|
|
1222
|
+
items,
|
|
1223
|
+
nextCursor: null,
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function scopePage(
|
|
1228
|
+
operation: string,
|
|
1229
|
+
items: Array<{ value: string; label: string; selectable: boolean }>
|
|
1230
|
+
) {
|
|
1231
|
+
return {
|
|
1232
|
+
schemaVersion: 'openxiangda.native-scope-value-page/v2',
|
|
1233
|
+
environment: {
|
|
1234
|
+
id: 'production-id',
|
|
1235
|
+
key: 'production',
|
|
1236
|
+
activeAppVersionId: 'app-version-1',
|
|
1237
|
+
headRevision: 7,
|
|
1238
|
+
authzRevisionId: 'authz-revision-1',
|
|
1239
|
+
authzVersion: 3,
|
|
1240
|
+
scopeDataVersion: 'scope-data-version-1',
|
|
1241
|
+
},
|
|
1242
|
+
resourceCode: 'instruments',
|
|
1243
|
+
fieldCode: 'collegeId',
|
|
1244
|
+
dimensionCode: 'college',
|
|
1245
|
+
operation,
|
|
1246
|
+
items,
|
|
1247
|
+
nextCursor: null,
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
async function fulfillRecord(
|
|
1252
|
+
route: Route,
|
|
1253
|
+
record: Record<string, unknown>
|
|
1254
|
+
) {
|
|
1255
|
+
await route.fulfill({
|
|
1256
|
+
contentType: 'application/json',
|
|
1257
|
+
body: JSON.stringify({
|
|
1258
|
+
code: 200,
|
|
1259
|
+
data: {
|
|
1260
|
+
schemaVersion: 'openxiangda.data-record/v2',
|
|
1261
|
+
resourceCode: 'instruments',
|
|
1262
|
+
data: record,
|
|
1263
|
+
},
|
|
1264
|
+
}),
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
async function fulfillCollegeRecord(
|
|
1269
|
+
route: Route,
|
|
1270
|
+
record: Record<string, unknown>
|
|
1271
|
+
) {
|
|
1272
|
+
await route.fulfill({
|
|
1273
|
+
contentType: 'application/json',
|
|
1274
|
+
body: JSON.stringify({
|
|
1275
|
+
code: 200,
|
|
1276
|
+
data: {
|
|
1277
|
+
schemaVersion: 'openxiangda.data-record/v2',
|
|
1278
|
+
resourceCode: 'colleges',
|
|
1279
|
+
data: record,
|
|
1280
|
+
},
|
|
1281
|
+
}),
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
async function fulfillIdentity(
|
|
1286
|
+
route: Route,
|
|
1287
|
+
roleCode: string,
|
|
1288
|
+
capabilityCodes: string[]
|
|
1289
|
+
) {
|
|
1290
|
+
await route.fulfill({
|
|
1291
|
+
contentType: 'application/json',
|
|
1292
|
+
body: JSON.stringify({
|
|
1293
|
+
code: 200,
|
|
1294
|
+
data: {
|
|
1295
|
+
schemaVersion: 'openxiangda.current-user/v2',
|
|
1296
|
+
appCode: 'instrument-center',
|
|
1297
|
+
environment: {
|
|
1298
|
+
id: 'production-id',
|
|
1299
|
+
key: 'production',
|
|
1300
|
+
activeAppVersionId: 'app-version-1',
|
|
1301
|
+
headRevision: 7,
|
|
1302
|
+
authzRevisionId: 'authz-revision-1',
|
|
1303
|
+
authzVersion: 3,
|
|
1304
|
+
scopeDataVersion: 'scope-data-version-1',
|
|
1305
|
+
},
|
|
1306
|
+
principal: {
|
|
1307
|
+
type: 'user',
|
|
1308
|
+
userId,
|
|
1309
|
+
roleCodes: [roleCode],
|
|
1310
|
+
capabilityCodes,
|
|
1311
|
+
isAppSuperAdmin: false,
|
|
1312
|
+
},
|
|
1313
|
+
},
|
|
1314
|
+
}),
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function instrumentFixture() {
|
|
1319
|
+
return {
|
|
1320
|
+
id: '092b0000-0000-4000-8000-000000000001',
|
|
1321
|
+
revision: 2,
|
|
1322
|
+
instrumentCode: 'INS-001',
|
|
1323
|
+
chineseName: '场发射扫描电子显微镜',
|
|
1324
|
+
specModel: 'SU8600',
|
|
1325
|
+
categoryId: 'analysis_microscope',
|
|
1326
|
+
assetCode: 'ASSET-001',
|
|
1327
|
+
enabledDate: '2025-01-01',
|
|
1328
|
+
usageStatus: 'active',
|
|
1329
|
+
instrumentStatus: 'normal',
|
|
1330
|
+
openToOutside: true,
|
|
1331
|
+
collegeId,
|
|
1332
|
+
manageDepartmentId: departmentId,
|
|
1333
|
+
useDepartmentId: departmentId,
|
|
1334
|
+
instrumentAdminIds: [userId],
|
|
1335
|
+
contactUserIds: [userId],
|
|
1336
|
+
contactPhone: '13800138000',
|
|
1337
|
+
};
|
|
1338
|
+
}
|