openxiangda-cli 2.0.0-alpha.85 → 2.0.0-alpha.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/template/AGENTS.md +4 -4
- package/template/README.md +4 -4
- package/template/apps/server/package.json +1 -1
- package/template/apps/server/src/app.module.ts +14 -1
- package/template/apps/server/test/smoke.test.ts +3 -1
- package/template/apps/web/data-api-live.e2e.html +15 -0
- package/template/apps/web/e2e/data-api-live-fixture.tsx +141 -0
- package/template/apps/web/e2e/data-api-live.spec.ts +138 -0
- package/template/apps/web/e2e/field-protocol-fixture.tsx +280 -0
- package/template/apps/web/e2e/field-protocol.spec.ts +100 -0
- package/template/apps/web/e2e/resources.spec.ts +258 -20
- package/template/apps/web/field-protocol.e2e.html +12 -0
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/playwright.config.ts +3 -1
- package/template/apps/web/scripts/check.mjs +8 -5
- package/template/apps/web/scripts/verify-build.mjs +1 -1
- package/template/apps/web/src/AuthoritativeSelector.tsx +165 -132
- package/template/apps/web/src/FilePreviewPage.tsx +30 -9
- package/template/apps/web/src/RoleSubjectSelect.tsx +128 -0
- package/template/apps/web/src/Shell.tsx +93 -6
- package/template/apps/web/src/components/platform-fields/AddressField.tsx +338 -0
- package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +25 -6
- package/template/apps/web/src/components/platform-fields/CascadeField.tsx +49 -0
- package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +148 -0
- package/template/apps/web/src/components/platform-fields/JsonField.tsx +77 -0
- package/template/apps/web/src/components/platform-fields/LocationField.tsx +164 -0
- package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +19 -44
- package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +67 -0
- package/template/apps/web/src/components/platform-fields/RichTextField.tsx +196 -0
- package/template/apps/web/src/components/platform-fields/SignatureField.tsx +315 -0
- package/template/apps/web/src/components/platform-fields/SubtableField.tsx +553 -0
- package/template/apps/web/src/components/platform-fields/address-value.ts +80 -0
- package/template/apps/web/src/components/platform-fields/cascade-value.ts +66 -0
- package/template/apps/web/src/components/platform-fields/directory-value.ts +53 -0
- package/template/apps/web/src/components/platform-fields/field-form-codec.ts +98 -0
- package/template/apps/web/src/components/platform-fields/location-value.ts +87 -0
- package/template/apps/web/src/components/platform-fields/resource-query.ts +131 -0
- package/template/apps/web/src/components/platform-fields/rich-text-value.ts +59 -0
- package/template/apps/web/src/components/platform-fields/subtable-value.ts +187 -0
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +194 -108
- package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +8 -1
- package/template/apps/web/src/components/resource/SurfaceFields.tsx +385 -79
- package/template/apps/web/src/components/resource/generated-resource-definition.ts +18 -0
- package/template/apps/web/src/components/resource/resource-import.ts +4 -4
- package/template/apps/web/src/data-provider.ts +59 -34
- package/template/apps/web/src/platform-client.ts +341 -136
- package/template/apps/web/src/runtime.tsx +156 -17
- package/template/apps/web/src/styles.css +264 -0
- package/template/apps/web/test/address-field.test.ts +66 -0
- package/template/apps/web/test/cascade-value.test.ts +68 -0
- package/template/apps/web/test/contracts.test.ts +29 -5
- package/template/apps/web/test/directory-value.test.ts +55 -0
- package/template/apps/web/test/field-bounds.test.ts +17 -0
- package/template/apps/web/test/field-form-codec.test.ts +85 -0
- package/template/apps/web/test/location-field.test.ts +83 -0
- package/template/apps/web/test/resource-import.test.ts +8 -7
- package/template/apps/web/test/resource-query.test.ts +144 -0
- package/template/apps/web/test/rich-json-field.test.ts +53 -0
- package/template/apps/web/test/signature-serial-field.test.ts +40 -0
- package/template/apps/web/test/subtable-value.test.ts +144 -0
- package/template/openxiangda.config.ts +1 -1
- package/template/package.json +3 -3
- package/template/packages/contracts/src/generated.ts +95 -0
- package/template/scripts/verify-template-budget.mjs +11 -4
|
@@ -4,8 +4,12 @@ import {
|
|
|
4
4
|
type DataFilePreview,
|
|
5
5
|
type DataFileRef,
|
|
6
6
|
type DataFileUploadPlan,
|
|
7
|
+
type DataFieldSourcePage,
|
|
8
|
+
type DataFieldSourceQuery,
|
|
9
|
+
type DataExportRequest,
|
|
7
10
|
type DataPage,
|
|
8
11
|
type DataQuery,
|
|
12
|
+
type DataWhere,
|
|
9
13
|
type DataRecord,
|
|
10
14
|
type DataResourceSurface,
|
|
11
15
|
type DataTransactionOperation,
|
|
@@ -13,10 +17,22 @@ import {
|
|
|
13
17
|
type DataTransactionResult,
|
|
14
18
|
type DirectoryEntryPage,
|
|
15
19
|
type DirectoryResolveRequest,
|
|
16
|
-
type
|
|
20
|
+
type RoleSessionContext,
|
|
21
|
+
type RoleSubjectChoice,
|
|
22
|
+
type RoleSubjectChoicePage,
|
|
23
|
+
type ResourceReferenceValue,
|
|
17
24
|
} from 'openxiangda-contracts/browser';
|
|
18
|
-
import { applicationCode, runtimeMount } from './runtime-meta';
|
|
19
25
|
import { useSyncExternalStore } from 'react';
|
|
26
|
+
import {
|
|
27
|
+
buildResourceWhere,
|
|
28
|
+
type GenericResourceQuery,
|
|
29
|
+
} from './components/platform-fields/resource-query';
|
|
30
|
+
export type { GenericResourceQuery } from './components/platform-fields/resource-query';
|
|
31
|
+
import {
|
|
32
|
+
applicationApiPath,
|
|
33
|
+
applicationCode,
|
|
34
|
+
runtimeMount,
|
|
35
|
+
} from './runtime-meta';
|
|
20
36
|
|
|
21
37
|
interface PlatformEnvelope<T> {
|
|
22
38
|
code: number | string;
|
|
@@ -53,14 +69,18 @@ export function useGlobalRequestLoading() {
|
|
|
53
69
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
54
70
|
beginGlobalRequest();
|
|
55
71
|
try {
|
|
72
|
+
const headers = new Headers(init?.headers);
|
|
73
|
+
if (!headers.has('accept')) headers.set('accept', 'application/json');
|
|
74
|
+
if (init?.body && !headers.has('content-type')) {
|
|
75
|
+
headers.set('content-type', 'application/json');
|
|
76
|
+
}
|
|
77
|
+
if (activeRoleSessionId && roleSessionBoundPath(path)) {
|
|
78
|
+
headers.set('x-openxiangda-role-session-id', activeRoleSessionId);
|
|
79
|
+
}
|
|
56
80
|
const response = await fetch(path, {
|
|
57
81
|
credentials: 'include',
|
|
58
82
|
...init,
|
|
59
|
-
headers
|
|
60
|
-
accept: 'application/json',
|
|
61
|
-
...(init?.body ? { 'content-type': 'application/json' } : {}),
|
|
62
|
-
...init?.headers,
|
|
63
|
-
},
|
|
83
|
+
headers,
|
|
64
84
|
});
|
|
65
85
|
const payload = (await response.json().catch(() => null)) as PlatformEnvelope<T> | T | null;
|
|
66
86
|
const envelope = payload && typeof payload === 'object' && 'code' in payload ? (payload as PlatformEnvelope<T>) : null;
|
|
@@ -81,18 +101,74 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
|
81
101
|
}
|
|
82
102
|
}
|
|
83
103
|
|
|
104
|
+
async function requestBlob(path: string, init?: RequestInit) {
|
|
105
|
+
beginGlobalRequest();
|
|
106
|
+
try {
|
|
107
|
+
const headers = new Headers(init?.headers);
|
|
108
|
+
headers.set('accept', 'text/csv');
|
|
109
|
+
if (init?.body && !headers.has('content-type')) {
|
|
110
|
+
headers.set('content-type', 'application/json');
|
|
111
|
+
}
|
|
112
|
+
if (activeRoleSessionId && roleSessionBoundPath(path)) {
|
|
113
|
+
headers.set('x-openxiangda-role-session-id', activeRoleSessionId);
|
|
114
|
+
}
|
|
115
|
+
const response = await fetch(path, {
|
|
116
|
+
credentials: 'include',
|
|
117
|
+
...init,
|
|
118
|
+
headers,
|
|
119
|
+
});
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
const payload = (await response.json().catch(() => null)) as PlatformEnvelope<unknown> | null;
|
|
122
|
+
const requestId = payload?.requestId ? ` (requestId: ${payload.requestId})` : '';
|
|
123
|
+
throw Object.assign(
|
|
124
|
+
new Error(`${payload?.errorCode || `HTTP_${response.status}`}: ${payload?.message || '平台导出失败'}${requestId}`),
|
|
125
|
+
{
|
|
126
|
+
code: payload?.errorCode || `HTTP_${response.status}`,
|
|
127
|
+
status: response.status,
|
|
128
|
+
data: payload?.data ?? null,
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const disposition = response.headers.get('content-disposition') || '';
|
|
133
|
+
const encodedName = /filename\*=UTF-8''([^;]+)/i.exec(disposition)?.[1];
|
|
134
|
+
return {
|
|
135
|
+
blob: await response.blob(),
|
|
136
|
+
fileName: encodedName ? decodeURIComponent(encodedName) : undefined,
|
|
137
|
+
rowLimit: Number(response.headers.get('x-openxiangda-export-row-limit') || 10_000),
|
|
138
|
+
};
|
|
139
|
+
} finally {
|
|
140
|
+
endGlobalRequest();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function roleSessionBoundPath(path: string) {
|
|
145
|
+
if (path.startsWith(applicationServiceBase())) return true;
|
|
146
|
+
const mount = runtimeMount();
|
|
147
|
+
return Boolean(
|
|
148
|
+
mount &&
|
|
149
|
+
path.startsWith(
|
|
150
|
+
`/service/openxiangda-app-api/v2/${encodeURIComponent(mount.appCode)}/${encodeURIComponent(mount.environmentKey)}/`
|
|
151
|
+
)
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function requestApplicationApi<T>(
|
|
156
|
+
path: string,
|
|
157
|
+
init?: RequestInit
|
|
158
|
+
) {
|
|
159
|
+
return await request<T>(applicationApiPath(path), init);
|
|
160
|
+
}
|
|
161
|
+
|
|
84
162
|
export interface RuntimeIdentity {
|
|
85
163
|
userId: string;
|
|
86
164
|
roleCodes: string[];
|
|
87
165
|
capabilityCodes: string[];
|
|
88
166
|
isAppSuperAdmin: boolean;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
contextRevision: number;
|
|
95
|
-
};
|
|
167
|
+
identityScope: string;
|
|
168
|
+
subjectProfile: RoleSessionContext['subjectProfile'] | null;
|
|
169
|
+
currentRoleSubject: RoleSubjectChoice | null;
|
|
170
|
+
roleSubjectPage: RoleSubjectChoicePage | null;
|
|
171
|
+
roleSwitchingAvailable: boolean;
|
|
96
172
|
environment: {
|
|
97
173
|
id: string;
|
|
98
174
|
key: 'preproduction' | 'production';
|
|
@@ -109,85 +185,206 @@ interface ConnectedCurrent {
|
|
|
109
185
|
principal: Pick<RuntimeIdentity, 'userId' | 'roleCodes' | 'capabilityCodes' | 'isAppSuperAdmin'>;
|
|
110
186
|
}
|
|
111
187
|
|
|
112
|
-
|
|
188
|
+
export interface RuntimeAuthorization {
|
|
189
|
+
state: RoleSessionContext['state'];
|
|
190
|
+
identity: RuntimeIdentity | null;
|
|
191
|
+
roleContext: RoleSessionContext | null;
|
|
192
|
+
}
|
|
113
193
|
|
|
114
194
|
let activeIdentity: RuntimeIdentity | undefined;
|
|
195
|
+
let activeRoleSessionId: string | undefined;
|
|
196
|
+
|
|
197
|
+
function applicationServiceBase() {
|
|
198
|
+
return `/service/openxiangda-api/v2/applications/${applicationCode()}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function validateRoleContext(context: RoleSessionContext): RuntimeAuthorization {
|
|
202
|
+
const mount = runtimeMount();
|
|
203
|
+
if (
|
|
204
|
+
!mount ||
|
|
205
|
+
context.schemaVersion !== SCHEMA_VERSIONS.roleSessionContext ||
|
|
206
|
+
context.environment.key !== mount.environmentKey
|
|
207
|
+
) {
|
|
208
|
+
throw new Error('OPENXIANGDA_ROLE_SESSION_CONTEXT_INVALID');
|
|
209
|
+
}
|
|
210
|
+
if (context.state !== 'active') {
|
|
211
|
+
if (context.roleSession || context.principal || context.currentRoleSubject) {
|
|
212
|
+
throw new Error('OPENXIANGDA_ROLE_SESSION_CONTEXT_INACTIVE_INVALID');
|
|
213
|
+
}
|
|
214
|
+
activeIdentity = undefined;
|
|
215
|
+
activeRoleSessionId = undefined;
|
|
216
|
+
return { state: context.state, identity: null, roleContext: context };
|
|
217
|
+
}
|
|
218
|
+
if (
|
|
219
|
+
!context.roleSession ||
|
|
220
|
+
!context.principal ||
|
|
221
|
+
!context.currentRoleSubject ||
|
|
222
|
+
!context.identityScope ||
|
|
223
|
+
!context.principal.activeRoleCode ||
|
|
224
|
+
context.principal.appCode !== mount.appCode ||
|
|
225
|
+
context.principal.roleSessionId !== context.roleSession.id ||
|
|
226
|
+
context.principal.activeRoleCode !== context.currentRoleSubject.role.code
|
|
227
|
+
) {
|
|
228
|
+
throw new Error('OPENXIANGDA_ROLE_SESSION_CONTEXT_ACTIVE_INVALID');
|
|
229
|
+
}
|
|
230
|
+
activeRoleSessionId = context.roleSession.id;
|
|
231
|
+
activeIdentity = {
|
|
232
|
+
userId: context.principal.userId,
|
|
233
|
+
roleCodes: [context.principal.activeRoleCode],
|
|
234
|
+
capabilityCodes: context.principal.capabilities,
|
|
235
|
+
isAppSuperAdmin: context.principal.isAppSuperAdmin,
|
|
236
|
+
identityScope: context.identityScope,
|
|
237
|
+
subjectProfile: context.subjectProfile,
|
|
238
|
+
currentRoleSubject: context.currentRoleSubject,
|
|
239
|
+
roleSubjectPage: context.roleSubjectPage,
|
|
240
|
+
roleSwitchingAvailable: true,
|
|
241
|
+
environment: {
|
|
242
|
+
id: context.environment.id,
|
|
243
|
+
key: context.environment.key,
|
|
244
|
+
activeAppVersionId: context.environment.activeAppVersionId,
|
|
245
|
+
headRevision: context.environment.headRevision,
|
|
246
|
+
authzRevisionId: context.principal.authzRevisionId,
|
|
247
|
+
authzVersion: context.principal.authzVersion,
|
|
248
|
+
scopeDataVersion: context.principal.scopeDataVersion,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
return { state: context.state, identity: activeIdentity, roleContext: context };
|
|
252
|
+
}
|
|
115
253
|
|
|
116
|
-
export async function
|
|
254
|
+
export async function loadRuntimeAuthorization(): Promise<RuntimeAuthorization> {
|
|
117
255
|
const mount = runtimeMount();
|
|
118
256
|
if (mount) {
|
|
119
|
-
const
|
|
120
|
-
`${nativeBase()}/
|
|
257
|
+
const context = await request<RoleSessionContext>(
|
|
258
|
+
`${nativeBase()}/authz/role-session/context?environmentKey=${encodeURIComponent(mount.environmentKey)}`
|
|
121
259
|
);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
260
|
+
return validateRoleContext(context);
|
|
261
|
+
}
|
|
262
|
+
const current = await request<ConnectedCurrent>(
|
|
263
|
+
`${applicationServiceBase()}/dev-sessions/current`
|
|
264
|
+
);
|
|
265
|
+
activeRoleSessionId = undefined;
|
|
266
|
+
activeIdentity = {
|
|
267
|
+
...current.principal,
|
|
268
|
+
identityScope: `connected-dev:${applicationCode()}:${current.environment.key}:${current.principal.userId}`,
|
|
269
|
+
subjectProfile: null,
|
|
270
|
+
currentRoleSubject: null,
|
|
271
|
+
roleSubjectPage: null,
|
|
272
|
+
roleSwitchingAvailable: false,
|
|
273
|
+
environment: current.environment,
|
|
274
|
+
};
|
|
275
|
+
return { state: 'active', identity: activeIdentity, roleContext: null };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function loadRoleSubjects(input: {
|
|
279
|
+
keyword?: string;
|
|
280
|
+
cursor?: string;
|
|
281
|
+
limit?: number;
|
|
282
|
+
} = {}) {
|
|
283
|
+
const query = new URLSearchParams({
|
|
284
|
+
environmentKey: currentEnvironmentKey(),
|
|
285
|
+
limit: String(Math.min(Math.max(input.limit || 20, 1), 50)),
|
|
286
|
+
});
|
|
287
|
+
if (input.keyword?.trim()) query.set('keyword', input.keyword.trim());
|
|
288
|
+
if (input.cursor) query.set('cursor', input.cursor);
|
|
289
|
+
return request<RoleSubjectChoicePage>(
|
|
290
|
+
`${nativeBase()}/authz/role-session/context/role-subjects?${query}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function switchRuntimeRole(
|
|
295
|
+
targetRoleSubjectKey: string,
|
|
296
|
+
expectedRoleSessionId: string | null
|
|
297
|
+
) {
|
|
298
|
+
const context = await request<RoleSessionContext>(
|
|
299
|
+
`${nativeBase()}/authz/role-session/context/switch`,
|
|
300
|
+
{
|
|
301
|
+
method: 'POST',
|
|
302
|
+
body: JSON.stringify({
|
|
303
|
+
targetRoleSubjectKey,
|
|
304
|
+
expectedRoleSessionId,
|
|
305
|
+
environmentKey: currentEnvironmentKey(),
|
|
306
|
+
}),
|
|
129
307
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
};
|
|
135
|
-
return activeIdentity;
|
|
308
|
+
);
|
|
309
|
+
const authorization = validateRoleContext(context);
|
|
310
|
+
if (!authorization.identity) {
|
|
311
|
+
throw new Error('OPENXIANGDA_ROLE_SESSION_SWITCH_INACTIVE');
|
|
136
312
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return activeIdentity;
|
|
313
|
+
publishRoleSessionInvalidation();
|
|
314
|
+
return authorization;
|
|
140
315
|
}
|
|
141
316
|
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
'openxiangda.
|
|
317
|
+
const ROLE_SESSION_CHANNEL = 'openxiangda.role-session-context/v2';
|
|
318
|
+
const ROLE_SESSION_STORAGE_KEY =
|
|
319
|
+
'openxiangda.role-session-context-invalidated/v2';
|
|
145
320
|
|
|
146
|
-
type
|
|
147
|
-
type: '
|
|
321
|
+
type RoleSessionInvalidation = {
|
|
322
|
+
type: 'role-session-context-invalidated';
|
|
148
323
|
appCode: string;
|
|
149
324
|
environmentKey: 'preproduction' | 'production';
|
|
150
|
-
contextRevision: number;
|
|
151
325
|
recordedAt: number;
|
|
152
326
|
};
|
|
153
327
|
|
|
154
|
-
function
|
|
328
|
+
function validRoleSessionInvalidation(
|
|
155
329
|
value: unknown
|
|
156
|
-
): value is
|
|
330
|
+
): value is RoleSessionInvalidation {
|
|
157
331
|
if (!value || typeof value !== 'object') return false;
|
|
158
|
-
const candidate = value as Partial<
|
|
332
|
+
const candidate = value as Partial<RoleSessionInvalidation>;
|
|
159
333
|
return (
|
|
160
|
-
candidate.type === '
|
|
334
|
+
candidate.type === 'role-session-context-invalidated' &&
|
|
161
335
|
typeof candidate.appCode === 'string' &&
|
|
162
336
|
(candidate.environmentKey === 'preproduction' ||
|
|
163
337
|
candidate.environmentKey === 'production') &&
|
|
164
|
-
Number.isSafeInteger(candidate.contextRevision) &&
|
|
165
338
|
typeof candidate.recordedAt === 'number'
|
|
166
339
|
);
|
|
167
340
|
}
|
|
168
341
|
|
|
169
|
-
|
|
342
|
+
function publishRoleSessionInvalidation() {
|
|
343
|
+
const mount = runtimeMount();
|
|
344
|
+
if (!mount || typeof window === 'undefined') return;
|
|
345
|
+
const event: RoleSessionInvalidation = {
|
|
346
|
+
type: 'role-session-context-invalidated',
|
|
347
|
+
appCode: mount.appCode,
|
|
348
|
+
environmentKey: mount.environmentKey,
|
|
349
|
+
recordedAt: Date.now(),
|
|
350
|
+
};
|
|
351
|
+
try {
|
|
352
|
+
const channel = new BroadcastChannel(ROLE_SESSION_CHANNEL);
|
|
353
|
+
channel.postMessage(event);
|
|
354
|
+
channel.close();
|
|
355
|
+
} catch {
|
|
356
|
+
// Storage below is the cross-tab fallback.
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
window.localStorage.setItem(ROLE_SESSION_STORAGE_KEY, JSON.stringify(event));
|
|
360
|
+
} catch {
|
|
361
|
+
// The active tab has already updated from the server response.
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function subscribeRoleSessionInvalidation(listener: () => void) {
|
|
170
366
|
const mount = runtimeMount();
|
|
171
367
|
if (!mount || typeof window === 'undefined') return () => undefined;
|
|
172
368
|
const receive = (value: unknown) => {
|
|
173
369
|
if (
|
|
174
|
-
|
|
370
|
+
validRoleSessionInvalidation(value) &&
|
|
175
371
|
value.appCode === mount.appCode &&
|
|
176
372
|
value.environmentKey === mount.environmentKey
|
|
177
373
|
) {
|
|
178
374
|
activeIdentity = undefined;
|
|
375
|
+
activeRoleSessionId = undefined;
|
|
179
376
|
listener();
|
|
180
377
|
}
|
|
181
378
|
};
|
|
182
379
|
let channel: BroadcastChannel | undefined;
|
|
183
380
|
try {
|
|
184
|
-
channel = new BroadcastChannel(
|
|
381
|
+
channel = new BroadcastChannel(ROLE_SESSION_CHANNEL);
|
|
185
382
|
channel.onmessage = event => receive(event.data);
|
|
186
383
|
} catch {
|
|
187
384
|
channel = undefined;
|
|
188
385
|
}
|
|
189
386
|
const onStorage = (event: StorageEvent) => {
|
|
190
|
-
if (event.key !==
|
|
387
|
+
if (event.key !== ROLE_SESSION_STORAGE_KEY || !event.newValue) return;
|
|
191
388
|
try {
|
|
192
389
|
receive(JSON.parse(event.newValue));
|
|
193
390
|
} catch {
|
|
@@ -206,6 +403,7 @@ export async function logoutCurrentUser() {
|
|
|
206
403
|
method: 'POST',
|
|
207
404
|
});
|
|
208
405
|
activeIdentity = undefined;
|
|
406
|
+
activeRoleSessionId = undefined;
|
|
209
407
|
}
|
|
210
408
|
|
|
211
409
|
function currentEnvironmentKey() {
|
|
@@ -216,6 +414,23 @@ function currentEnvironmentKey() {
|
|
|
216
414
|
|
|
217
415
|
export type DirectoryKind = 'user' | 'department';
|
|
218
416
|
|
|
417
|
+
export interface ChinaDivisionListItem {
|
|
418
|
+
adcode: string;
|
|
419
|
+
citycode?: string;
|
|
420
|
+
name: string;
|
|
421
|
+
level?: string;
|
|
422
|
+
center?: string;
|
|
423
|
+
parentAdcode?: string;
|
|
424
|
+
hasChildren: boolean;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export async function loadChinaDivisions(parentAdcode?: string) {
|
|
428
|
+
const params = new URLSearchParams();
|
|
429
|
+
if (parentAdcode) params.set('parentAdcode', parentAdcode);
|
|
430
|
+
const suffix = params.size ? `?${params}` : '';
|
|
431
|
+
return await request<ChinaDivisionListItem[]>(`/china-divisions/${suffix}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
219
434
|
export async function searchDirectory(kind: DirectoryKind, options: { keyword: string; cursor?: string }) {
|
|
220
435
|
const params = new URLSearchParams({
|
|
221
436
|
environmentKey: currentEnvironmentKey(),
|
|
@@ -262,66 +477,37 @@ export async function resolveDirectory(kind: DirectoryKind, ids: string[]) {
|
|
|
262
477
|
);
|
|
263
478
|
}
|
|
264
479
|
|
|
265
|
-
export async function searchResource(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
480
|
+
export async function searchResource(
|
|
481
|
+
resourceCode: string,
|
|
482
|
+
fieldCode: string,
|
|
483
|
+
options: {
|
|
484
|
+
operation: 'create' | 'update';
|
|
485
|
+
keyword: string;
|
|
486
|
+
cursor?: string;
|
|
487
|
+
bindings?: Record<string, unknown>;
|
|
488
|
+
}
|
|
489
|
+
) {
|
|
490
|
+
const query: DataFieldSourceQuery = {
|
|
491
|
+
schemaVersion: SCHEMA_VERSIONS.dataFieldSourceQuery,
|
|
492
|
+
operation: options.operation,
|
|
493
|
+
...(options.keyword.trim() ? { keyword: options.keyword.trim() } : {}),
|
|
494
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
495
|
+
...(options.bindings && Object.keys(options.bindings).length
|
|
496
|
+
? { bindings: options.bindings }
|
|
497
|
+
: {}),
|
|
282
498
|
};
|
|
283
|
-
|
|
499
|
+
return await request<DataFieldSourcePage>(
|
|
500
|
+
`${nativeBase()}/data-resources/${encodeURIComponent(
|
|
501
|
+
resourceCode
|
|
502
|
+
)}/fields/${encodeURIComponent(fieldCode)}/source/query`,
|
|
503
|
+
{
|
|
284
504
|
method: 'POST',
|
|
285
505
|
body: JSON.stringify({
|
|
286
506
|
...query,
|
|
287
507
|
environmentKey: currentEnvironmentKey(),
|
|
288
508
|
}),
|
|
289
|
-
|
|
290
|
-
return {
|
|
291
|
-
items: page.items.map((item) => ({
|
|
292
|
-
id: String(item.id),
|
|
293
|
-
label: String(item[labelField] ?? item.id),
|
|
294
|
-
selectable: true,
|
|
295
|
-
})),
|
|
296
|
-
nextCursor: page.items.length >= pageSize ? String(page.offset + page.items.length) : null,
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export async function resolveResource(resourceCode: string, ids: string[], labelField: string) {
|
|
301
|
-
if (ids.length === 0) throw new Error('OPENXIANGDA_RESOURCE_RESOLVE_IDS_REQUIRED');
|
|
302
|
-
if (ids.length > 50) throw new Error('OPENXIANGDA_RESOURCE_RESOLVE_IDS_EXCEEDED');
|
|
303
|
-
const items = await Promise.all(
|
|
304
|
-
ids.map(async (id) => {
|
|
305
|
-
const record = await request<DataRecord<Record<string, unknown>>>(
|
|
306
|
-
`${dataBase(resourceCode)}/records/${encodeURIComponent(id)}?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
|
|
307
|
-
);
|
|
308
|
-
return {
|
|
309
|
-
id,
|
|
310
|
-
label: String(record.data[labelField] ?? id),
|
|
311
|
-
selectable: true,
|
|
312
|
-
};
|
|
313
|
-
})
|
|
509
|
+
}
|
|
314
510
|
);
|
|
315
|
-
return { items, nextCursor: null };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
export interface GenericResourceQuery {
|
|
319
|
-
page: number;
|
|
320
|
-
pageSize: number;
|
|
321
|
-
keyword?: string;
|
|
322
|
-
searchField?: string;
|
|
323
|
-
filters?: Record<string, unknown>;
|
|
324
|
-
sort?: { field: string; order: 'asc' | 'desc' };
|
|
325
511
|
}
|
|
326
512
|
|
|
327
513
|
export function createNativeResourceClient(code: string, surface: DataResourceSurface) {
|
|
@@ -331,38 +517,6 @@ export function createNativeResourceClient(code: string, surface: DataResourceSu
|
|
|
331
517
|
if (!declaredFields.has(field)) throw new Error(`OPENXIANGDA_RESOURCE_QUERY_FIELD_NOT_DECLARED:${code}:${field}`);
|
|
332
518
|
return field;
|
|
333
519
|
};
|
|
334
|
-
const filtersFor = (query: GenericResourceQuery) => {
|
|
335
|
-
const filters: NonNullable<DataQuery['filters']> = [];
|
|
336
|
-
if (query.keyword?.trim()) {
|
|
337
|
-
const searchable = surface.list?.searchableFields || [];
|
|
338
|
-
const field = searchable.includes(query.searchField || '') ? query.searchField! : searchable[0];
|
|
339
|
-
if (field)
|
|
340
|
-
filters.push({
|
|
341
|
-
field: assertField(field),
|
|
342
|
-
operator: 'ilike',
|
|
343
|
-
value: `%${query.keyword.trim()}%`,
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
for (const [fieldCode, value] of Object.entries(query.filters || {})) {
|
|
347
|
-
if (value === undefined || value === null || value === '') continue;
|
|
348
|
-
// A list state can outlive a resource route during client-side navigation.
|
|
349
|
-
// Ignore filters from the previous resource instead of constructing an
|
|
350
|
-
// invalid Native Data query for the current resource.
|
|
351
|
-
if (!declaredFields.has(fieldCode)) continue;
|
|
352
|
-
const field = surface.fields[fieldCode];
|
|
353
|
-
if (Array.isArray(value) && value.length === 2 && ['date', 'datetime', 'number', 'money', 'percent'].includes(field?.widget || '')) {
|
|
354
|
-
if (value[0] !== undefined && value[0] !== null && value[0] !== '')
|
|
355
|
-
filters.push({ field: fieldCode, operator: 'gte', value: value[0] });
|
|
356
|
-
if (value[1] !== undefined && value[1] !== null && value[1] !== '')
|
|
357
|
-
filters.push({ field: fieldCode, operator: 'lte', value: value[1] });
|
|
358
|
-
} else if (Array.isArray(value) && ['multi', 'directory-user', 'directory-department', 'resource'].includes(field?.widget || '')) {
|
|
359
|
-
filters.push({ field: fieldCode, operator: 'cs', value });
|
|
360
|
-
} else {
|
|
361
|
-
filters.push({ field: fieldCode, operator: 'eq', value });
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
return filters;
|
|
365
|
-
};
|
|
366
520
|
return {
|
|
367
521
|
async list(query: GenericResourceQuery) {
|
|
368
522
|
const fallbackSort = surface.list?.defaultSort || {
|
|
@@ -371,9 +525,10 @@ export function createNativeResourceClient(code: string, surface: DataResourceSu
|
|
|
371
525
|
};
|
|
372
526
|
const requestedSort = query.sort || fallbackSort;
|
|
373
527
|
const sort = declaredFields.has(requestedSort.field) ? requestedSort : fallbackSort;
|
|
528
|
+
const where = buildResourceWhere(code, surface, query);
|
|
374
529
|
const body: DataQuery = {
|
|
375
530
|
schemaVersion: SCHEMA_VERSIONS.dataQuery,
|
|
376
|
-
|
|
531
|
+
...(where ? { where } : {}),
|
|
377
532
|
order: [{ field: sort.field, direction: sort.order || 'asc' }],
|
|
378
533
|
limit: query.pageSize,
|
|
379
534
|
offset: (query.page - 1) * query.pageSize,
|
|
@@ -387,6 +542,28 @@ export function createNativeResourceClient(code: string, surface: DataResourceSu
|
|
|
387
542
|
});
|
|
388
543
|
return { rows: page.items, total: page.total };
|
|
389
544
|
},
|
|
545
|
+
async exportCsv(query: GenericResourceQuery, select: string[]) {
|
|
546
|
+
const fallbackSort = surface.list?.defaultSort || {
|
|
547
|
+
field: Object.keys(surface.fields)[0] || 'id',
|
|
548
|
+
order: 'asc' as const,
|
|
549
|
+
};
|
|
550
|
+
const requestedSort = query.sort || fallbackSort;
|
|
551
|
+
const sort = declaredFields.has(requestedSort.field) ? requestedSort : fallbackSort;
|
|
552
|
+
const where = buildResourceWhere(code, surface, query);
|
|
553
|
+
const body: DataExportRequest = {
|
|
554
|
+
schemaVersion: SCHEMA_VERSIONS.dataExportRequest,
|
|
555
|
+
select: select.map(assertField),
|
|
556
|
+
...(where ? { where } : {}),
|
|
557
|
+
order: [{ field: sort.field, direction: sort.order || 'asc' }],
|
|
558
|
+
};
|
|
559
|
+
return await requestBlob(`${base}/export`, {
|
|
560
|
+
method: 'POST',
|
|
561
|
+
body: JSON.stringify({
|
|
562
|
+
...body,
|
|
563
|
+
environmentKey: currentEnvironmentKey(),
|
|
564
|
+
}),
|
|
565
|
+
});
|
|
566
|
+
},
|
|
390
567
|
async get(id: string) {
|
|
391
568
|
const record = await request<DataRecord<Record<string, unknown>>>(
|
|
392
569
|
`${base}/records/${encodeURIComponent(id)}?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
|
|
@@ -487,6 +664,10 @@ export function dataFileContentUrl(resource: string, fileId: string, disposition
|
|
|
487
664
|
return `${dataBase(resource)}/files/${encodeURIComponent(fileId)}/content?${query}`;
|
|
488
665
|
}
|
|
489
666
|
|
|
667
|
+
export function dataRichTextImageSource(resource: string, fileId: string) {
|
|
668
|
+
return `${dataBase(resource)}/files/${encodeURIComponent(fileId)}/content?disposition=inline`;
|
|
669
|
+
}
|
|
670
|
+
|
|
490
671
|
export async function loadDataFilePreview(resource: string, fileId: string) {
|
|
491
672
|
return request<DataFilePreview>(
|
|
492
673
|
`${dataBase(resource)}/files/${encodeURIComponent(fileId)}/preview?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
|
|
@@ -494,10 +675,34 @@ export async function loadDataFilePreview(resource: string, fileId: string) {
|
|
|
494
675
|
}
|
|
495
676
|
|
|
496
677
|
export async function fetchDataFileBlob(resource: string, fileId: string) {
|
|
678
|
+
const headers = new Headers({ accept: 'application/octet-stream,*/*' });
|
|
679
|
+
if (activeRoleSessionId) {
|
|
680
|
+
headers.set('x-openxiangda-role-session-id', activeRoleSessionId);
|
|
681
|
+
}
|
|
497
682
|
const response = await fetch(dataFileContentUrl(resource, fileId, 'inline'), {
|
|
498
683
|
credentials: 'include',
|
|
499
|
-
headers
|
|
684
|
+
headers,
|
|
500
685
|
});
|
|
501
686
|
if (!response.ok) throw new Error(`FILE_CONTENT_READ_FAILED: HTTP_${response.status}`);
|
|
502
687
|
return response.blob();
|
|
503
688
|
}
|
|
689
|
+
|
|
690
|
+
export async function downloadDataFile(
|
|
691
|
+
resource: string,
|
|
692
|
+
fileId: string,
|
|
693
|
+
fileName: string
|
|
694
|
+
) {
|
|
695
|
+
const blob = await fetchDataFileBlob(resource, fileId);
|
|
696
|
+
const url = URL.createObjectURL(blob);
|
|
697
|
+
try {
|
|
698
|
+
const anchor = document.createElement('a');
|
|
699
|
+
anchor.href = url;
|
|
700
|
+
anchor.download = fileName;
|
|
701
|
+
anchor.rel = 'noopener';
|
|
702
|
+
document.body.append(anchor);
|
|
703
|
+
anchor.click();
|
|
704
|
+
anchor.remove();
|
|
705
|
+
} finally {
|
|
706
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
707
|
+
}
|
|
708
|
+
}
|