openxiangda-cli 2.0.0-alpha.86 → 2.0.0-alpha.90
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/README.md +2 -2
- package/bin/run.js +1 -0
- package/dist/commands/skill.d.ts +26 -0
- package/dist/commands/skill.d.ts.map +1 -0
- package/dist/commands/skill.js +43 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/create-workspace.d.ts.map +1 -1
- package/dist/create-workspace.js +3 -3
- package/dist/create-workspace.js.map +1 -1
- package/dist/toolchain-capsule.d.ts.map +1 -1
- package/dist/toolchain-capsule.js +4 -8
- package/dist/toolchain-capsule.js.map +1 -1
- package/package.json +6 -7
- package/template/AGENTS.md +2 -1
- package/template/README.md +5 -5
- package/template/apps/server/package.json +1 -1
- package/template/apps/server/src/app.module.ts +14 -1
- package/template/apps/server/src/main.ts +1 -1
- package/template/apps/server/test/smoke.test.ts +3 -1
- package/template/apps/web/e2e/client-fixture.ts +5 -0
- package/template/apps/web/e2e/data-api-live-fixture.tsx +19 -14
- package/template/apps/web/e2e/field-protocol-fixture.tsx +87 -20
- package/template/apps/web/e2e/field-protocol.spec.ts +86 -0
- package/template/apps/web/e2e/resources.spec.ts +20 -181
- package/template/apps/web/field-protocol.e2e.html +3 -0
- package/template/apps/web/package.json +2 -5
- package/template/apps/web/src/main.tsx +12 -60
- package/template/apps/web/test/contracts.test.ts +52 -104
- package/template/openxiangda.config.ts +2 -2
- package/template/package.json +1 -3
- package/template/packages/contracts/src/generated.ts +27 -0
- package/template/scripts/verify-template-budget.mjs +2 -2
- package/template/apps/web/src/AuthoritativeSelector.tsx +0 -661
- package/template/apps/web/src/FilePreviewPage.tsx +0 -324
- package/template/apps/web/src/RoleSubjectSelect.tsx +0 -128
- package/template/apps/web/src/Shell.tsx +0 -445
- package/template/apps/web/src/appearance.tsx +0 -147
- package/template/apps/web/src/components/platform-fields/AddressField.tsx +0 -338
- package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +0 -245
- package/template/apps/web/src/components/platform-fields/CascadeField.tsx +0 -49
- package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +0 -148
- package/template/apps/web/src/components/platform-fields/JsonField.tsx +0 -77
- package/template/apps/web/src/components/platform-fields/LocationField.tsx +0 -164
- package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +0 -673
- package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +0 -67
- package/template/apps/web/src/components/platform-fields/RichTextField.tsx +0 -196
- package/template/apps/web/src/components/platform-fields/SignatureField.tsx +0 -315
- package/template/apps/web/src/components/platform-fields/SubtableField.tsx +0 -553
- package/template/apps/web/src/components/platform-fields/address-value.ts +0 -80
- package/template/apps/web/src/components/platform-fields/cascade-value.ts +0 -66
- package/template/apps/web/src/components/platform-fields/directory-value.ts +0 -53
- package/template/apps/web/src/components/platform-fields/field-form-codec.ts +0 -98
- package/template/apps/web/src/components/platform-fields/location-value.ts +0 -87
- package/template/apps/web/src/components/platform-fields/resource-query.ts +0 -131
- package/template/apps/web/src/components/platform-fields/rich-text-value.ts +0 -59
- package/template/apps/web/src/components/platform-fields/subtable-value.ts +0 -187
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +0 -1184
- package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +0 -326
- package/template/apps/web/src/components/resource/StandardResourcePages.tsx +0 -392
- package/template/apps/web/src/components/resource/SurfaceFields.tsx +0 -915
- package/template/apps/web/src/components/resource/generated-resource-definition.ts +0 -18
- package/template/apps/web/src/components/resource/resource-import.ts +0 -205
- package/template/apps/web/src/data-provider.ts +0 -72
- package/template/apps/web/src/platform-client.ts +0 -708
- package/template/apps/web/src/runtime-meta.ts +0 -75
- package/template/apps/web/src/runtime.tsx +0 -227
- package/template/apps/web/src/styles.css +0 -1941
- package/template/apps/web/test/address-field.test.ts +0 -66
- package/template/apps/web/test/cascade-value.test.ts +0 -68
- package/template/apps/web/test/directory-value.test.ts +0 -55
- package/template/apps/web/test/field-bounds.test.ts +0 -17
- package/template/apps/web/test/field-form-codec.test.ts +0 -85
- package/template/apps/web/test/location-field.test.ts +0 -83
- package/template/apps/web/test/resource-import.test.ts +0 -143
- package/template/apps/web/test/resource-query.test.ts +0 -144
- package/template/apps/web/test/rich-json-field.test.ts +0 -53
- package/template/apps/web/test/signature-serial-field.test.ts +0 -40
- package/template/apps/web/test/subtable-value.test.ts +0 -144
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
import { ConfigProvider, Form, Typography } from 'antd';
|
|
2
|
-
import type {
|
|
1
|
+
import { Button, ConfigProvider, Form, Typography } from 'antd';
|
|
2
|
+
import type {
|
|
3
|
+
DataAuditEntry,
|
|
4
|
+
DataFieldSurface,
|
|
5
|
+
DataFileRef,
|
|
6
|
+
} from 'openxiangda/core';
|
|
3
7
|
import React from 'react';
|
|
4
8
|
import ReactDOM from 'react-dom/client';
|
|
5
9
|
import {
|
|
10
|
+
auditFieldChange,
|
|
6
11
|
MobileSurfaceFieldControl,
|
|
12
|
+
SurfaceAuditFieldValue,
|
|
7
13
|
SurfaceFieldControl,
|
|
8
14
|
SurfaceFieldValue,
|
|
9
15
|
type SurfaceField,
|
|
10
16
|
type SurfaceFieldRenderers,
|
|
11
|
-
} from '
|
|
12
|
-
import '
|
|
17
|
+
} from 'openxiangda/field-kit';
|
|
18
|
+
import 'openxiangda/react/styles.css';
|
|
13
19
|
|
|
14
20
|
const options = [
|
|
15
21
|
{ label: '启用', value: 'enabled' },
|
|
@@ -98,7 +104,7 @@ const fields = [
|
|
|
98
104
|
field('业务签名', 'signature', 'signature'),
|
|
99
105
|
field('地址', 'address', 'address'),
|
|
100
106
|
field('精确定位', 'location', 'location'),
|
|
101
|
-
field('UUID', 'uuid', 'readonly'),
|
|
107
|
+
field('UUID', 'uuid', 'readonly', { requiredHint: true }),
|
|
102
108
|
field('JSON', 'json', 'json'),
|
|
103
109
|
field('流水号', 'serial-number', 'readonly'),
|
|
104
110
|
field('子表', 'subtable', 'subtable'),
|
|
@@ -117,17 +123,28 @@ const reference = {
|
|
|
117
123
|
snapshot: { code: 'M-001' },
|
|
118
124
|
};
|
|
119
125
|
const fileRef: DataFileRef = {
|
|
126
|
+
schemaVersion: 'openxiangda.data-file-ref/v2',
|
|
120
127
|
id: '11111111-1111-4111-8111-111111111111',
|
|
121
128
|
name: '验收附件.pdf',
|
|
122
129
|
size: 1280,
|
|
123
130
|
contentType: 'application/pdf',
|
|
124
131
|
};
|
|
132
|
+
const imageRef: DataFileRef = {
|
|
133
|
+
...fileRef,
|
|
134
|
+
id: '22222222-2222-4222-8222-222222222222',
|
|
135
|
+
name: '验收图片.png',
|
|
136
|
+
contentType: 'image/png',
|
|
137
|
+
width: 640,
|
|
138
|
+
height: 480,
|
|
139
|
+
previewUrl: '/service/openxiangda-api/v2/applications/field-protocol-fixture/native/data/field-records/files/22222222-2222-4222-8222-222222222222/content?disposition=inline',
|
|
140
|
+
thumbnailUrl: '/service/openxiangda-api/v2/applications/field-protocol-fixture/native/data/field-records/files/22222222-2222-4222-8222-222222222222/content?variant=thumbnail&disposition=inline',
|
|
141
|
+
};
|
|
125
142
|
const values: Record<string, unknown> = {
|
|
126
143
|
单行文本: '仪器验收记录',
|
|
127
144
|
邮箱: 'user@example.com',
|
|
128
145
|
电话: '13800000000',
|
|
129
146
|
多行文本: '第一行\n第二行',
|
|
130
|
-
富文本:
|
|
147
|
+
富文本: `<p>已保存的<strong>富文本</strong></p><img src="${imageRef.previewUrl}" alt="富文本图片">`,
|
|
131
148
|
整数: -7,
|
|
132
149
|
小数: 12.34,
|
|
133
150
|
金额: 5600.5,
|
|
@@ -164,16 +181,7 @@ const values: Record<string, unknown> = {
|
|
|
164
181
|
资源单选: reference,
|
|
165
182
|
资源多选: [reference],
|
|
166
183
|
附件: [fileRef],
|
|
167
|
-
图片: [
|
|
168
|
-
{
|
|
169
|
-
...fileRef,
|
|
170
|
-
id: '22222222-2222-4222-8222-222222222222',
|
|
171
|
-
name: '验收图片.png',
|
|
172
|
-
contentType: 'image/png',
|
|
173
|
-
width: 640,
|
|
174
|
-
height: 480,
|
|
175
|
-
},
|
|
176
|
-
],
|
|
184
|
+
图片: [imageRef],
|
|
177
185
|
业务签名: {
|
|
178
186
|
file: {
|
|
179
187
|
...fileRef,
|
|
@@ -201,15 +209,38 @@ const values: Record<string, unknown> = {
|
|
|
201
209
|
accuracy: 5,
|
|
202
210
|
capturedAt: '2026-08-24T02:20:30.000Z',
|
|
203
211
|
},
|
|
204
|
-
UUID: '44444444-4444-4444-8444-444444444444',
|
|
205
212
|
JSON: { status: 'ok', nested: { count: 1 } },
|
|
206
213
|
流水号: 'FP-001000',
|
|
207
214
|
子表: [{ item_name: '第一行' }, { item_name: '第二行' }],
|
|
208
215
|
};
|
|
209
216
|
|
|
217
|
+
const auditEntry: DataAuditEntry = {
|
|
218
|
+
id: 'field-protocol-event',
|
|
219
|
+
operation: 'created',
|
|
220
|
+
recordId: 'field-protocol-record',
|
|
221
|
+
revision: 1,
|
|
222
|
+
actor: { principalType: 'user', subjectId: 'user-1' },
|
|
223
|
+
correlation: {
|
|
224
|
+
eventId: 'field-protocol-event',
|
|
225
|
+
requestId: null,
|
|
226
|
+
traceId: null,
|
|
227
|
+
environmentKey: 'preproduction',
|
|
228
|
+
appVersionId: 'field-protocol-version',
|
|
229
|
+
environmentHeadRevision: 1,
|
|
230
|
+
capturePlanRevision: 1,
|
|
231
|
+
cause: { eventId: null, subscriptionCode: null, depth: 0 },
|
|
232
|
+
},
|
|
233
|
+
changes: Object.fromEntries(
|
|
234
|
+
fields.map(item => [item.key, { after: values[item.key] ?? null }])
|
|
235
|
+
),
|
|
236
|
+
projection: {},
|
|
237
|
+
occurredAt: '2026-08-25T00:00:00.000Z',
|
|
238
|
+
};
|
|
239
|
+
|
|
210
240
|
const renderers: SurfaceFieldRenderers = {
|
|
211
241
|
signer: user,
|
|
212
242
|
upload: async (_field, file) => ({
|
|
243
|
+
schemaVersion: 'openxiangda.data-file-ref/v2',
|
|
213
244
|
id: crypto.randomUUID(),
|
|
214
245
|
name: file.name,
|
|
215
246
|
size: file.size,
|
|
@@ -239,12 +270,12 @@ function AcceptancePage() {
|
|
|
239
270
|
</Typography.Text>
|
|
240
271
|
</header>
|
|
241
272
|
<section aria-label={mobile ? '移动编辑字段' : '桌面编辑字段'} className="field-protocol-edit">
|
|
242
|
-
<Form initialValues={values} layout="vertical">
|
|
273
|
+
<Form initialValues={values} layout="vertical" onFinish={() => undefined}>
|
|
243
274
|
<div className="field-protocol-grid">
|
|
244
275
|
{fields.map(item => (
|
|
245
276
|
<div data-field-code={item.key} key={item.key}>
|
|
246
277
|
<Control
|
|
247
|
-
disabled={
|
|
278
|
+
disabled={item.widget === 'readonly'}
|
|
248
279
|
field={item}
|
|
249
280
|
operation="create"
|
|
250
281
|
renderers={renderers}
|
|
@@ -253,6 +284,7 @@ function AcceptancePage() {
|
|
|
253
284
|
</div>
|
|
254
285
|
))}
|
|
255
286
|
</div>
|
|
287
|
+
<Button htmlType="submit">验证表单</Button>
|
|
256
288
|
</Form>
|
|
257
289
|
</section>
|
|
258
290
|
{!mobile && (
|
|
@@ -262,9 +294,44 @@ function AcceptancePage() {
|
|
|
262
294
|
{fields.map(item => (
|
|
263
295
|
<div data-read-field={item.key} key={item.key}>
|
|
264
296
|
<strong>{item.label}</strong>
|
|
265
|
-
<SurfaceFieldValue
|
|
297
|
+
<SurfaceFieldValue
|
|
298
|
+
field={item}
|
|
299
|
+
renderers={renderers}
|
|
300
|
+
resourceCode="field-records"
|
|
301
|
+
value={values[item.key]}
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
</section>
|
|
307
|
+
)}
|
|
308
|
+
{!mobile && (
|
|
309
|
+
<section aria-label="当前协议字段变更记录" className="field-protocol-readonly">
|
|
310
|
+
<Typography.Title level={3}>当前协议字段变更记录</Typography.Title>
|
|
311
|
+
<div className="field-protocol-read-grid">
|
|
312
|
+
{fields.map(item => (
|
|
313
|
+
<div data-audit-field={item.key} key={item.key}>
|
|
314
|
+
<strong>{item.label}</strong>
|
|
315
|
+
<SurfaceAuditFieldValue
|
|
316
|
+
field={item}
|
|
317
|
+
resourceCode="field-records"
|
|
318
|
+
value={auditFieldChange(auditEntry, item.key)?.after}
|
|
319
|
+
/>
|
|
266
320
|
</div>
|
|
267
321
|
))}
|
|
322
|
+
<div data-audit-digest="rich-text">
|
|
323
|
+
<strong>富文本摘要</strong>
|
|
324
|
+
<SurfaceAuditFieldValue
|
|
325
|
+
field={fields[4]}
|
|
326
|
+
value={{
|
|
327
|
+
kind: 'digest',
|
|
328
|
+
truncated: true,
|
|
329
|
+
valueType: 'text.rich',
|
|
330
|
+
bytes: 65_537,
|
|
331
|
+
sha256: 'a'.repeat(64),
|
|
332
|
+
}}
|
|
333
|
+
/>
|
|
334
|
+
</div>
|
|
268
335
|
</div>
|
|
269
336
|
</section>
|
|
270
337
|
)}
|
|
@@ -23,13 +23,32 @@ test.describe('Field Kit protocol acceptance', () => {
|
|
|
23
23
|
},
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
|
+
await page.route('**/files/*/content?*', async route => {
|
|
27
|
+
await route.fulfill({
|
|
28
|
+
body: Buffer.from(
|
|
29
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
|
30
|
+
'base64'
|
|
31
|
+
),
|
|
32
|
+
contentType: 'image/png',
|
|
33
|
+
status: 200,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
26
36
|
});
|
|
27
37
|
|
|
28
38
|
test('renders and operates every desktop edit/read component', async ({ page }) => {
|
|
39
|
+
const pageErrors: string[] = [];
|
|
40
|
+
const addressRequests: string[] = [];
|
|
41
|
+
page.on('pageerror', error => pageErrors.push(error.message));
|
|
42
|
+
page.on('request', request => {
|
|
43
|
+
if (request.url().includes('/china-divisions/')) {
|
|
44
|
+
addressRequests.push(request.url());
|
|
45
|
+
}
|
|
46
|
+
});
|
|
29
47
|
await page.setViewportSize({ width: 1440, height: 1100 });
|
|
30
48
|
await page.goto('/field-protocol.e2e.html');
|
|
31
49
|
await expect(page.locator('[data-field-code]')).toHaveCount(fieldCount);
|
|
32
50
|
await expect(page.locator('[data-read-field]')).toHaveCount(fieldCount);
|
|
51
|
+
await expect(page.locator('[data-audit-field]')).toHaveCount(fieldCount);
|
|
33
52
|
await expect(page.locator('[data-field-code="静态单选下拉"] .ant-select')).toBeVisible();
|
|
34
53
|
await expect(page.locator('[data-field-code="单选按钮"] .ant-radio-group')).toBeVisible();
|
|
35
54
|
await expect(page.locator('[data-field-code="复选框"] .ant-checkbox-group')).toBeVisible();
|
|
@@ -37,6 +56,22 @@ test.describe('Field Kit protocol acceptance', () => {
|
|
|
37
56
|
await expect(page.locator('[data-field-code="精确定位"] input')).toHaveCount(0);
|
|
38
57
|
await expect(page.locator('[data-field-code="精确定位"]')).not.toContainText('手工');
|
|
39
58
|
await expect(page.locator('.ant-alert-error')).toHaveCount(0);
|
|
59
|
+
await expect.poll(() => addressRequests.length).toBeGreaterThan(0);
|
|
60
|
+
expect(addressRequests.every(url => new URL(url).pathname.startsWith('/service/china-divisions/'))).toBe(true);
|
|
61
|
+
|
|
62
|
+
await page.getByRole('button', { name: '验证表单' }).click();
|
|
63
|
+
await expect(page.locator('[data-field-code="UUID"] .ant-form-item-has-error')).toHaveCount(0);
|
|
64
|
+
|
|
65
|
+
await expect(page.locator('[data-read-field="图片"] img.oxa-file-thumbnail')).toHaveAttribute('src', /blob:/);
|
|
66
|
+
await expect(page.locator('[data-read-field="富文本"] img')).toHaveAttribute('src', /blob:/);
|
|
67
|
+
expect(await page.locator('[data-read-field="图片"] img.oxa-file-thumbnail').evaluate(
|
|
68
|
+
image => (image as HTMLImageElement).naturalWidth
|
|
69
|
+
)).toBeGreaterThan(0);
|
|
70
|
+
expect(await page.locator('[data-read-field="富文本"] img').evaluate(
|
|
71
|
+
image => (image as HTMLImageElement).naturalWidth
|
|
72
|
+
)).toBeGreaterThan(0);
|
|
73
|
+
await expect(page.locator('[aria-label="当前协议字段变更记录"]')).not.toContainText('[object Object]');
|
|
74
|
+
await expect(page.locator('[data-audit-digest="rich-text"]')).toContainText('大值摘要');
|
|
40
75
|
|
|
41
76
|
await page.locator('[data-field-code="精确定位"]').getByRole('button', { name: '浏览器定位' }).click();
|
|
42
77
|
await expect(page.locator('[data-field-code="精确定位"]')).toContainText('120.123456');
|
|
@@ -78,12 +113,63 @@ test.describe('Field Kit protocol acceptance', () => {
|
|
|
78
113
|
await saveSignature.click();
|
|
79
114
|
await expect(page.locator('[data-field-code="业务签名"]')).not.toContainText('尚未签名');
|
|
80
115
|
|
|
116
|
+
expect(pageErrors).toEqual([]);
|
|
117
|
+
|
|
81
118
|
expect(
|
|
82
119
|
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
|
83
120
|
).toBe(true);
|
|
84
121
|
await page.screenshot({ fullPage: true, path: 'test-results/field-protocol-desktop.png' });
|
|
85
122
|
});
|
|
86
123
|
|
|
124
|
+
test('resource selectors query once when selection changes', async ({ page }) => {
|
|
125
|
+
const requestCounts = new Map<string, number>();
|
|
126
|
+
await page.route('**/source/query', async route => {
|
|
127
|
+
const match = new URL(route.request().url()).pathname.match(/\/fields\/([^/]+)\/source\/query$/);
|
|
128
|
+
const fieldCode = decodeURIComponent(match?.[1] || 'unknown');
|
|
129
|
+
requestCounts.set(fieldCode, (requestCounts.get(fieldCode) || 0) + 1);
|
|
130
|
+
const body = route.request().postDataJSON() as { operation?: 'create' | 'update' };
|
|
131
|
+
await route.fulfill({
|
|
132
|
+
contentType: 'application/json',
|
|
133
|
+
json: {
|
|
134
|
+
schemaVersion: 'openxiangda.data-field-source-page/v2',
|
|
135
|
+
environment: {
|
|
136
|
+
id: 'field-protocol-environment',
|
|
137
|
+
key: 'preproduction',
|
|
138
|
+
activeAppVersionId: 'field-protocol-version',
|
|
139
|
+
headRevision: 1,
|
|
140
|
+
authzRevisionId: 'field-protocol-authz',
|
|
141
|
+
authzVersion: 1,
|
|
142
|
+
scopeDataVersion: 'field-protocol-scope',
|
|
143
|
+
},
|
|
144
|
+
resourceCode: 'field-records',
|
|
145
|
+
fieldCode,
|
|
146
|
+
sourceResourceCode: 'field-lookups',
|
|
147
|
+
operation: body.operation || 'create',
|
|
148
|
+
items: [{
|
|
149
|
+
label: `候选-${fieldCode}`,
|
|
150
|
+
value: `candidate-${fieldCode}`,
|
|
151
|
+
resourceCode: 'field-lookups',
|
|
152
|
+
snapshot: { code: `code-${fieldCode}` },
|
|
153
|
+
}],
|
|
154
|
+
nextCursor: null,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await page.goto('/field-protocol.e2e.html');
|
|
160
|
+
for (const fieldCode of ['资源单选', '资源多选']) {
|
|
161
|
+
await page.locator(`[data-field-code="${fieldCode}"]`).getByRole('combobox').click();
|
|
162
|
+
await expect.poll(() => requestCounts.get(fieldCode) || 0).toBe(1);
|
|
163
|
+
await page
|
|
164
|
+
.locator('.ant-select-dropdown:visible .oxa-selector-option')
|
|
165
|
+
.filter({ hasText: `候选-${fieldCode}` })
|
|
166
|
+
.click();
|
|
167
|
+
await page.waitForTimeout(500);
|
|
168
|
+
expect(requestCounts.get(fieldCode)).toBe(1);
|
|
169
|
+
await page.keyboard.press('Escape');
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
87
173
|
test('renders every mobile control without horizontal overflow', async ({ page }) => {
|
|
88
174
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
89
175
|
await page.goto('/field-protocol.e2e.html?mode=mobile');
|
|
@@ -21,119 +21,26 @@ const definitions = resourceDefinitions as Record<string, DeclaredResource>;
|
|
|
21
21
|
const allCapabilities = [...capabilities] as string[];
|
|
22
22
|
const runtimeBase = `/view/${appCode}`;
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
{
|
|
26
|
-
subjectKey: 'membership:college-admin',
|
|
27
|
-
subjectKind: 'membership',
|
|
28
|
-
role: {
|
|
29
|
-
code: 'college_admin',
|
|
30
|
-
name: '学院管理员',
|
|
31
|
-
source: 'package',
|
|
32
|
-
description: '管理本学院业务数据',
|
|
33
|
-
},
|
|
34
|
-
revision: 1,
|
|
35
|
-
scopeDimensionCount: 1,
|
|
36
|
-
scopeSummary: [],
|
|
37
|
-
scopeSummaryTruncated: false,
|
|
38
|
-
validFrom: null,
|
|
39
|
-
validTo: null,
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
subjectKey: 'membership:instrument-admin',
|
|
43
|
-
subjectKind: 'membership',
|
|
44
|
-
role: {
|
|
45
|
-
code: 'instrument_admin',
|
|
46
|
-
name: '仪器管理员',
|
|
47
|
-
source: 'package',
|
|
48
|
-
description: '管理分配的仪器',
|
|
49
|
-
},
|
|
50
|
-
revision: 1,
|
|
51
|
-
scopeDimensionCount: 0,
|
|
52
|
-
scopeSummary: [],
|
|
53
|
-
scopeSummaryTruncated: false,
|
|
54
|
-
validFrom: null,
|
|
55
|
-
validTo: null,
|
|
56
|
-
},
|
|
57
|
-
] as const;
|
|
58
|
-
|
|
59
|
-
function roleContext(subjectKey: string, authorized: boolean) {
|
|
60
|
-
const subject = roleSubjects.find(item => item.subjectKey === subjectKey)!;
|
|
61
|
-
const roleSessionId = `role-session-${subject.role.code}`;
|
|
24
|
+
function runtimeAuthorization(authorized: boolean) {
|
|
62
25
|
return {
|
|
63
|
-
schemaVersion: 'openxiangda.
|
|
26
|
+
schemaVersion: 'openxiangda.runtime-authorization/v2',
|
|
64
27
|
state: 'active',
|
|
65
28
|
environment: {
|
|
66
29
|
id: 'preproduction-id',
|
|
67
30
|
key: 'preproduction',
|
|
68
|
-
kind: 'preproduction',
|
|
69
31
|
activeAppVersionId: 'app-version-1',
|
|
70
32
|
headRevision: 1,
|
|
33
|
+
authzRevisionId: 'authz-1',
|
|
71
34
|
authzVersion: 1,
|
|
72
|
-
|
|
73
|
-
},
|
|
74
|
-
identityScope: `preproduction-id:${subjectKey}:1`,
|
|
75
|
-
subjectProfile: {
|
|
76
|
-
schemaVersion: 'openxiangda.subject-profile/v2',
|
|
77
|
-
userId: 'acceptance-user',
|
|
78
|
-
displayName: '验收用户',
|
|
79
|
-
avatarUrl: null,
|
|
80
|
-
jobNumber: null,
|
|
81
|
-
affiliatedDepartment: { id: 'department-1', name: '实验中心' },
|
|
82
|
-
},
|
|
83
|
-
currentRoleSubject: subject,
|
|
84
|
-
roleSubjectPage: {
|
|
85
|
-
schemaVersion: 'openxiangda.role-subject-page/v2',
|
|
86
|
-
items: roleSubjects,
|
|
87
|
-
total: roleSubjects.length,
|
|
88
|
-
nextCursor: null,
|
|
89
|
-
roleSubjectSetVersion: 'subjects-1',
|
|
90
|
-
},
|
|
91
|
-
roleSession: {
|
|
92
|
-
schemaVersion: 'openxiangda.native-role-session/v2',
|
|
93
|
-
id: roleSessionId,
|
|
94
|
-
environmentId: 'preproduction-id',
|
|
95
|
-
environmentKey: 'preproduction',
|
|
96
|
-
loginSessionId: 'login-session-1',
|
|
97
|
-
userId: 'acceptance-user',
|
|
98
|
-
selectionKey: subjectKey,
|
|
99
|
-
subjectKind: 'membership',
|
|
100
|
-
activeRoleCode: subject.role.code,
|
|
101
|
-
activeRoleSource: 'package',
|
|
102
|
-
activeRoleMembershipId: subjectKey.slice('membership:'.length),
|
|
103
|
-
activeAuthzRevisionId: 'authz-1',
|
|
104
|
-
activeAuthzVersion: 1,
|
|
105
|
-
activeAppVersionId: 'app-version-1',
|
|
106
|
-
activeEnvironmentHeadRevision: 1,
|
|
107
|
-
activeMembershipRevision: 1,
|
|
108
|
-
status: 'active',
|
|
109
|
-
issuedAt: '2026-08-23T00:00:00.000Z',
|
|
110
|
-
expiresAt: '2099-08-23T00:00:00.000Z',
|
|
111
|
-
lastUsedAt: '2026-08-23T00:00:00.000Z',
|
|
35
|
+
scopeDataVersion: 'scope-1',
|
|
112
36
|
},
|
|
113
37
|
principal: {
|
|
114
|
-
|
|
115
|
-
tenantId: 'tenant-1',
|
|
116
|
-
appCode,
|
|
117
|
-
environmentId: 'preproduction-id',
|
|
118
|
-
environmentKey: 'preproduction',
|
|
119
|
-
activeAppVersionId: 'app-version-1',
|
|
120
|
-
environmentHeadRevision: 1,
|
|
121
|
-
principalType: 'user',
|
|
122
|
-
subjectId: 'acceptance-user',
|
|
38
|
+
type: 'user_union',
|
|
123
39
|
userId: 'acceptance-user',
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
subjectKind: 'membership',
|
|
127
|
-
activeRoleCode: subject.role.code,
|
|
128
|
-
activeRoleSource: 'package',
|
|
129
|
-
activeRoleMembershipId: subjectKey.slice('membership:'.length),
|
|
130
|
-
activeMembershipRevision: 1,
|
|
131
|
-
authzRevisionId: 'authz-1',
|
|
132
|
-
authzVersion: 1,
|
|
133
|
-
scopeDataVersion: 'scope-1',
|
|
40
|
+
roleCodes: ['college_admin', 'instrument_admin'],
|
|
41
|
+
capabilityCodes: authorized ? allCapabilities : [],
|
|
134
42
|
isAppSuperAdmin: false,
|
|
135
|
-
|
|
136
|
-
expiresAt: null,
|
|
43
|
+
identityScope: 'user-union:preproduction-id:acceptance-user:authz-1',
|
|
137
44
|
},
|
|
138
45
|
};
|
|
139
46
|
}
|
|
@@ -143,10 +50,8 @@ async function mockPlatform(
|
|
|
143
50
|
authorized = true,
|
|
144
51
|
targetRuntimeBase = runtimeBase,
|
|
145
52
|
) {
|
|
146
|
-
let activeSubjectKey = roleSubjects[0].subjectKey;
|
|
147
53
|
let contextReads = 0;
|
|
148
54
|
const observed: Array<{ path: string; roleSessionId: string }> = [];
|
|
149
|
-
const switches: Array<Record<string, unknown>> = [];
|
|
150
55
|
await page.route(`**${targetRuntimeBase}/**`, async route => {
|
|
151
56
|
if (route.request().resourceType() !== 'document') return route.continue();
|
|
152
57
|
const response = await route.fetch();
|
|
@@ -162,42 +67,13 @@ async function mockPlatform(
|
|
|
162
67
|
const url = new URL(route.request().url());
|
|
163
68
|
const request = route.request();
|
|
164
69
|
const roleSessionId = request.headers()['x-openxiangda-role-session-id'] || '';
|
|
165
|
-
if (url.pathname.endsWith('/native/authz/
|
|
166
|
-
return route.fulfill({
|
|
167
|
-
contentType: 'application/json',
|
|
168
|
-
body: JSON.stringify({
|
|
169
|
-
code: 200,
|
|
170
|
-
data: {
|
|
171
|
-
schemaVersion: 'openxiangda.role-subject-page/v2',
|
|
172
|
-
items: roleSubjects,
|
|
173
|
-
total: roleSubjects.length,
|
|
174
|
-
nextCursor: null,
|
|
175
|
-
roleSubjectSetVersion: 'subjects-1',
|
|
176
|
-
},
|
|
177
|
-
}),
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
if (url.pathname.endsWith('/native/authz/role-session/context/switch')) {
|
|
181
|
-
const body = request.postDataJSON() as Record<string, unknown>;
|
|
182
|
-
switches.push(body);
|
|
183
|
-
if (roleSubjects.some(item => item.subjectKey === body.targetRoleSubjectKey)) {
|
|
184
|
-
activeSubjectKey = String(body.targetRoleSubjectKey) as typeof activeSubjectKey;
|
|
185
|
-
}
|
|
186
|
-
return route.fulfill({
|
|
187
|
-
contentType: 'application/json',
|
|
188
|
-
body: JSON.stringify({
|
|
189
|
-
code: 200,
|
|
190
|
-
data: roleContext(activeSubjectKey, authorized),
|
|
191
|
-
}),
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
if (url.pathname.endsWith('/native/authz/role-session/context')) {
|
|
70
|
+
if (url.pathname.endsWith('/native/authz/current')) {
|
|
195
71
|
contextReads += 1;
|
|
196
72
|
return route.fulfill({
|
|
197
73
|
contentType: 'application/json',
|
|
198
74
|
body: JSON.stringify({
|
|
199
75
|
code: 200,
|
|
200
|
-
data:
|
|
76
|
+
data: runtimeAuthorization(authorized),
|
|
201
77
|
}),
|
|
202
78
|
});
|
|
203
79
|
}
|
|
@@ -262,7 +138,6 @@ async function mockPlatform(
|
|
|
262
138
|
});
|
|
263
139
|
return {
|
|
264
140
|
observed,
|
|
265
|
-
switches,
|
|
266
141
|
get contextReads() {
|
|
267
142
|
return contextReads;
|
|
268
143
|
},
|
|
@@ -314,7 +189,7 @@ test('opens the stable admin entry under the preproduction gateway mount', async
|
|
|
314
189
|
await expect(page).toHaveURL(`${devRuntimeBase}/${codes[0]}`);
|
|
315
190
|
});
|
|
316
191
|
|
|
317
|
-
test('
|
|
192
|
+
test('uses the current user role union without exposing a RoleSession header', async ({ page }) => {
|
|
318
193
|
const platform = await mockPlatform(page);
|
|
319
194
|
await page.goto(`${runtimeBase}/admin`);
|
|
320
195
|
if (codes.length) {
|
|
@@ -324,7 +199,7 @@ test('binds Data, Directory, App API, and role switching to one active RoleSessi
|
|
|
324
199
|
}
|
|
325
200
|
|
|
326
201
|
await page.evaluate(async () => {
|
|
327
|
-
const client = await import('/
|
|
202
|
+
const client = await import('/e2e/client-fixture.ts');
|
|
328
203
|
await client.searchResource('acceptance-resource', 'name', {
|
|
329
204
|
keyword: '显微镜',
|
|
330
205
|
});
|
|
@@ -332,55 +207,19 @@ test('binds Data, Directory, App API, and role switching to one active RoleSessi
|
|
|
332
207
|
await client.requestApplicationApi('/health/current-user');
|
|
333
208
|
});
|
|
334
209
|
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
/\/native\/data(?:-resources)?\/|\/directory\/|\/openxiangda-app-api\/v2\//.test(
|
|
339
|
-
item.path
|
|
340
|
-
)
|
|
210
|
+
const protectedRequests = platform.observed.filter(item =>
|
|
211
|
+
/\/native\/data(?:-resources)?\/|\/directory\/|\/openxiangda-app-api\/v2\//.test(
|
|
212
|
+
item.path
|
|
341
213
|
)
|
|
342
|
-
).toEqual(
|
|
343
|
-
expect.arrayContaining([
|
|
344
|
-
expect.objectContaining({ roleSessionId: initialSession }),
|
|
345
|
-
expect.objectContaining({ roleSessionId: initialSession }),
|
|
346
|
-
expect.objectContaining({ roleSessionId: initialSession }),
|
|
347
|
-
])
|
|
348
214
|
);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
await page.getByRole('combobox', { name: '选择应用角色' }).click();
|
|
353
|
-
await page.getByText('仪器管理员', { exact: true }).click();
|
|
354
|
-
await page.getByRole('button', { name: '切换角色' }).click();
|
|
355
|
-
|
|
356
|
-
await expect.poll(() => platform.switches.length).toBe(1);
|
|
357
|
-
expect(platform.switches[0]).toMatchObject({
|
|
358
|
-
targetRoleSubjectKey: 'membership:instrument-admin',
|
|
359
|
-
expectedRoleSessionId: initialSession,
|
|
360
|
-
environmentKey: 'preproduction',
|
|
361
|
-
});
|
|
362
|
-
await expect.poll(() => platform.contextReads).toBeGreaterThan(1);
|
|
215
|
+
expect(protectedRequests.length).toBeGreaterThanOrEqual(3);
|
|
216
|
+
expect(protectedRequests.every(item => item.roleSessionId === '')).toBe(true);
|
|
217
|
+
expect(platform.contextReads).toBe(1);
|
|
363
218
|
await page.locator('.oxa-current-user').click();
|
|
364
219
|
await expect(page.locator('.oxa-user-dropdown-roles')).toContainText(
|
|
365
|
-
'
|
|
220
|
+
'college_admin、instrument_admin'
|
|
366
221
|
);
|
|
367
|
-
await page.
|
|
368
|
-
await page.evaluate(async () => {
|
|
369
|
-
const client = await import('/src/platform-client.ts');
|
|
370
|
-
await client.searchResource('acceptance-resource', 'name', {
|
|
371
|
-
keyword: '光谱仪',
|
|
372
|
-
});
|
|
373
|
-
});
|
|
374
|
-
await expect
|
|
375
|
-
.poll(
|
|
376
|
-
() =>
|
|
377
|
-
platform.observed.filter(
|
|
378
|
-
item =>
|
|
379
|
-
item.path.includes('/native/data-resources/') &&
|
|
380
|
-
item.roleSessionId === 'role-session-instrument_admin'
|
|
381
|
-
).length
|
|
382
|
-
)
|
|
383
|
-
.toBeGreaterThan(0);
|
|
222
|
+
await expect(page.getByText('切换应用角色', { exact: true })).toHaveCount(0);
|
|
384
223
|
});
|
|
385
224
|
|
|
386
225
|
test('renders platform mobile controls from declared field widgets', async ({ page }) => {
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="openxiangda-runtime-base" content="/" />
|
|
7
|
+
<meta name="openxiangda-app-code" content="field-protocol-fixture" />
|
|
8
|
+
<meta name="openxiangda-environment" content="preproduction" />
|
|
6
9
|
<title>Field protocol acceptance</title>
|
|
7
10
|
</head>
|
|
8
11
|
<body>
|
|
@@ -14,13 +14,10 @@
|
|
|
14
14
|
"@ant-design/icons": "6.2.3",
|
|
15
15
|
"@refinedev/core": "5.0.12",
|
|
16
16
|
"antd": "6.4.3",
|
|
17
|
-
"
|
|
18
|
-
"docx-preview": "0.3.7",
|
|
19
|
-
"openxiangda-contracts": "2.0.0-alpha.40",
|
|
17
|
+
"openxiangda": "2.0.0-alpha.2",
|
|
20
18
|
"react": "19.2.8",
|
|
21
19
|
"react-dom": "19.2.8",
|
|
22
|
-
"react-router-dom": "7.8.2"
|
|
23
|
-
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
|
20
|
+
"react-router-dom": "7.8.2"
|
|
24
21
|
},
|
|
25
22
|
"devDependencies": {
|
|
26
23
|
"@playwright/test": "1.62.1",
|