openxiangda-cli 2.0.0-alpha.84 → 2.0.0-alpha.86
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 +2 -2
- package/template/apps/server/package.json +1 -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 +385 -111
- package/template/apps/web/src/runtime.tsx +166 -14
- 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/package.json +3 -3
- package/template/packages/contracts/src/generated.ts +69 -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,9 +17,22 @@ import {
|
|
|
13
17
|
type DataTransactionResult,
|
|
14
18
|
type DirectoryEntryPage,
|
|
15
19
|
type DirectoryResolveRequest,
|
|
20
|
+
type RoleSessionContext,
|
|
21
|
+
type RoleSubjectChoice,
|
|
22
|
+
type RoleSubjectChoicePage,
|
|
23
|
+
type ResourceReferenceValue,
|
|
16
24
|
} from 'openxiangda-contracts/browser';
|
|
17
|
-
import { applicationCode, runtimeMount } from './runtime-meta';
|
|
18
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';
|
|
19
36
|
|
|
20
37
|
interface PlatformEnvelope<T> {
|
|
21
38
|
code: number | string;
|
|
@@ -52,14 +69,18 @@ export function useGlobalRequestLoading() {
|
|
|
52
69
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
53
70
|
beginGlobalRequest();
|
|
54
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
|
+
}
|
|
55
80
|
const response = await fetch(path, {
|
|
56
81
|
credentials: 'include',
|
|
57
82
|
...init,
|
|
58
|
-
headers
|
|
59
|
-
accept: 'application/json',
|
|
60
|
-
...(init?.body ? { 'content-type': 'application/json' } : {}),
|
|
61
|
-
...init?.headers,
|
|
62
|
-
},
|
|
83
|
+
headers,
|
|
63
84
|
});
|
|
64
85
|
const payload = (await response.json().catch(() => null)) as PlatformEnvelope<T> | T | null;
|
|
65
86
|
const envelope = payload && typeof payload === 'object' && 'code' in payload ? (payload as PlatformEnvelope<T>) : null;
|
|
@@ -80,11 +101,74 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
|
80
101
|
}
|
|
81
102
|
}
|
|
82
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
|
+
|
|
83
162
|
export interface RuntimeIdentity {
|
|
84
163
|
userId: string;
|
|
85
164
|
roleCodes: string[];
|
|
86
165
|
capabilityCodes: string[];
|
|
87
166
|
isAppSuperAdmin: boolean;
|
|
167
|
+
identityScope: string;
|
|
168
|
+
subjectProfile: RoleSessionContext['subjectProfile'] | null;
|
|
169
|
+
currentRoleSubject: RoleSubjectChoice | null;
|
|
170
|
+
roleSubjectPage: RoleSubjectChoicePage | null;
|
|
171
|
+
roleSwitchingAvailable: boolean;
|
|
88
172
|
environment: {
|
|
89
173
|
id: string;
|
|
90
174
|
key: 'preproduction' | 'production';
|
|
@@ -101,35 +185,217 @@ interface ConnectedCurrent {
|
|
|
101
185
|
principal: Pick<RuntimeIdentity, 'userId' | 'roleCodes' | 'capabilityCodes' | 'isAppSuperAdmin'>;
|
|
102
186
|
}
|
|
103
187
|
|
|
104
|
-
interface
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
principal: { type: 'user' } & Pick<RuntimeIdentity, 'userId' | 'roleCodes' | 'capabilityCodes' | 'isAppSuperAdmin'>;
|
|
188
|
+
export interface RuntimeAuthorization {
|
|
189
|
+
state: RoleSessionContext['state'];
|
|
190
|
+
identity: RuntimeIdentity | null;
|
|
191
|
+
roleContext: RoleSessionContext | null;
|
|
109
192
|
}
|
|
110
193
|
|
|
111
194
|
let activeIdentity: RuntimeIdentity | undefined;
|
|
195
|
+
let activeRoleSessionId: string | undefined;
|
|
112
196
|
|
|
113
|
-
|
|
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
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function loadRuntimeAuthorization(): Promise<RuntimeAuthorization> {
|
|
114
255
|
const mount = runtimeMount();
|
|
115
256
|
if (mount) {
|
|
116
|
-
const
|
|
117
|
-
`${nativeBase()}/
|
|
257
|
+
const context = await request<RoleSessionContext>(
|
|
258
|
+
`${nativeBase()}/authz/role-session/context?environmentKey=${encodeURIComponent(mount.environmentKey)}`
|
|
118
259
|
);
|
|
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
|
+
}),
|
|
307
|
+
}
|
|
308
|
+
);
|
|
309
|
+
const authorization = validateRoleContext(context);
|
|
310
|
+
if (!authorization.identity) {
|
|
311
|
+
throw new Error('OPENXIANGDA_ROLE_SESSION_SWITCH_INACTIVE');
|
|
312
|
+
}
|
|
313
|
+
publishRoleSessionInvalidation();
|
|
314
|
+
return authorization;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const ROLE_SESSION_CHANNEL = 'openxiangda.role-session-context/v2';
|
|
318
|
+
const ROLE_SESSION_STORAGE_KEY =
|
|
319
|
+
'openxiangda.role-session-context-invalidated/v2';
|
|
320
|
+
|
|
321
|
+
type RoleSessionInvalidation = {
|
|
322
|
+
type: 'role-session-context-invalidated';
|
|
323
|
+
appCode: string;
|
|
324
|
+
environmentKey: 'preproduction' | 'production';
|
|
325
|
+
recordedAt: number;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
function validRoleSessionInvalidation(
|
|
329
|
+
value: unknown
|
|
330
|
+
): value is RoleSessionInvalidation {
|
|
331
|
+
if (!value || typeof value !== 'object') return false;
|
|
332
|
+
const candidate = value as Partial<RoleSessionInvalidation>;
|
|
333
|
+
return (
|
|
334
|
+
candidate.type === 'role-session-context-invalidated' &&
|
|
335
|
+
typeof candidate.appCode === 'string' &&
|
|
336
|
+
(candidate.environmentKey === 'preproduction' ||
|
|
337
|
+
candidate.environmentKey === 'production') &&
|
|
338
|
+
typeof candidate.recordedAt === 'number'
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
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) {
|
|
366
|
+
const mount = runtimeMount();
|
|
367
|
+
if (!mount || typeof window === 'undefined') return () => undefined;
|
|
368
|
+
const receive = (value: unknown) => {
|
|
119
369
|
if (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
current.principal.type !== 'user'
|
|
370
|
+
validRoleSessionInvalidation(value) &&
|
|
371
|
+
value.appCode === mount.appCode &&
|
|
372
|
+
value.environmentKey === mount.environmentKey
|
|
124
373
|
) {
|
|
125
|
-
|
|
374
|
+
activeIdentity = undefined;
|
|
375
|
+
activeRoleSessionId = undefined;
|
|
376
|
+
listener();
|
|
126
377
|
}
|
|
127
|
-
|
|
128
|
-
|
|
378
|
+
};
|
|
379
|
+
let channel: BroadcastChannel | undefined;
|
|
380
|
+
try {
|
|
381
|
+
channel = new BroadcastChannel(ROLE_SESSION_CHANNEL);
|
|
382
|
+
channel.onmessage = event => receive(event.data);
|
|
383
|
+
} catch {
|
|
384
|
+
channel = undefined;
|
|
129
385
|
}
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
386
|
+
const onStorage = (event: StorageEvent) => {
|
|
387
|
+
if (event.key !== ROLE_SESSION_STORAGE_KEY || !event.newValue) return;
|
|
388
|
+
try {
|
|
389
|
+
receive(JSON.parse(event.newValue));
|
|
390
|
+
} catch {
|
|
391
|
+
// Invalid browser signals never alter the server-owned identity.
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
window.addEventListener('storage', onStorage);
|
|
395
|
+
return () => {
|
|
396
|
+
channel?.close();
|
|
397
|
+
window.removeEventListener('storage', onStorage);
|
|
398
|
+
};
|
|
133
399
|
}
|
|
134
400
|
|
|
135
401
|
export async function logoutCurrentUser() {
|
|
@@ -137,6 +403,7 @@ export async function logoutCurrentUser() {
|
|
|
137
403
|
method: 'POST',
|
|
138
404
|
});
|
|
139
405
|
activeIdentity = undefined;
|
|
406
|
+
activeRoleSessionId = undefined;
|
|
140
407
|
}
|
|
141
408
|
|
|
142
409
|
function currentEnvironmentKey() {
|
|
@@ -147,6 +414,23 @@ function currentEnvironmentKey() {
|
|
|
147
414
|
|
|
148
415
|
export type DirectoryKind = 'user' | 'department';
|
|
149
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
|
+
|
|
150
434
|
export async function searchDirectory(kind: DirectoryKind, options: { keyword: string; cursor?: string }) {
|
|
151
435
|
const params = new URLSearchParams({
|
|
152
436
|
environmentKey: currentEnvironmentKey(),
|
|
@@ -193,66 +477,37 @@ export async function resolveDirectory(kind: DirectoryKind, ids: string[]) {
|
|
|
193
477
|
);
|
|
194
478
|
}
|
|
195
479
|
|
|
196
|
-
export async function searchResource(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
+
: {}),
|
|
213
498
|
};
|
|
214
|
-
|
|
499
|
+
return await request<DataFieldSourcePage>(
|
|
500
|
+
`${nativeBase()}/data-resources/${encodeURIComponent(
|
|
501
|
+
resourceCode
|
|
502
|
+
)}/fields/${encodeURIComponent(fieldCode)}/source/query`,
|
|
503
|
+
{
|
|
215
504
|
method: 'POST',
|
|
216
505
|
body: JSON.stringify({
|
|
217
506
|
...query,
|
|
218
507
|
environmentKey: currentEnvironmentKey(),
|
|
219
508
|
}),
|
|
220
|
-
|
|
221
|
-
return {
|
|
222
|
-
items: page.items.map((item) => ({
|
|
223
|
-
id: String(item.id),
|
|
224
|
-
label: String(item[labelField] ?? item.id),
|
|
225
|
-
selectable: true,
|
|
226
|
-
})),
|
|
227
|
-
nextCursor: page.items.length >= pageSize ? String(page.offset + page.items.length) : null,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export async function resolveResource(resourceCode: string, ids: string[], labelField: string) {
|
|
232
|
-
if (ids.length === 0) throw new Error('OPENXIANGDA_RESOURCE_RESOLVE_IDS_REQUIRED');
|
|
233
|
-
if (ids.length > 50) throw new Error('OPENXIANGDA_RESOURCE_RESOLVE_IDS_EXCEEDED');
|
|
234
|
-
const items = await Promise.all(
|
|
235
|
-
ids.map(async (id) => {
|
|
236
|
-
const record = await request<DataRecord<Record<string, unknown>>>(
|
|
237
|
-
`${dataBase(resourceCode)}/records/${encodeURIComponent(id)}?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
|
|
238
|
-
);
|
|
239
|
-
return {
|
|
240
|
-
id,
|
|
241
|
-
label: String(record.data[labelField] ?? id),
|
|
242
|
-
selectable: true,
|
|
243
|
-
};
|
|
244
|
-
})
|
|
509
|
+
}
|
|
245
510
|
);
|
|
246
|
-
return { items, nextCursor: null };
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export interface GenericResourceQuery {
|
|
250
|
-
page: number;
|
|
251
|
-
pageSize: number;
|
|
252
|
-
keyword?: string;
|
|
253
|
-
searchField?: string;
|
|
254
|
-
filters?: Record<string, unknown>;
|
|
255
|
-
sort?: { field: string; order: 'asc' | 'desc' };
|
|
256
511
|
}
|
|
257
512
|
|
|
258
513
|
export function createNativeResourceClient(code: string, surface: DataResourceSurface) {
|
|
@@ -262,38 +517,6 @@ export function createNativeResourceClient(code: string, surface: DataResourceSu
|
|
|
262
517
|
if (!declaredFields.has(field)) throw new Error(`OPENXIANGDA_RESOURCE_QUERY_FIELD_NOT_DECLARED:${code}:${field}`);
|
|
263
518
|
return field;
|
|
264
519
|
};
|
|
265
|
-
const filtersFor = (query: GenericResourceQuery) => {
|
|
266
|
-
const filters: NonNullable<DataQuery['filters']> = [];
|
|
267
|
-
if (query.keyword?.trim()) {
|
|
268
|
-
const searchable = surface.list?.searchableFields || [];
|
|
269
|
-
const field = searchable.includes(query.searchField || '') ? query.searchField! : searchable[0];
|
|
270
|
-
if (field)
|
|
271
|
-
filters.push({
|
|
272
|
-
field: assertField(field),
|
|
273
|
-
operator: 'ilike',
|
|
274
|
-
value: `%${query.keyword.trim()}%`,
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
for (const [fieldCode, value] of Object.entries(query.filters || {})) {
|
|
278
|
-
if (value === undefined || value === null || value === '') continue;
|
|
279
|
-
// A list state can outlive a resource route during client-side navigation.
|
|
280
|
-
// Ignore filters from the previous resource instead of constructing an
|
|
281
|
-
// invalid Native Data query for the current resource.
|
|
282
|
-
if (!declaredFields.has(fieldCode)) continue;
|
|
283
|
-
const field = surface.fields[fieldCode];
|
|
284
|
-
if (Array.isArray(value) && value.length === 2 && ['date', 'datetime', 'number', 'money', 'percent'].includes(field?.widget || '')) {
|
|
285
|
-
if (value[0] !== undefined && value[0] !== null && value[0] !== '')
|
|
286
|
-
filters.push({ field: fieldCode, operator: 'gte', value: value[0] });
|
|
287
|
-
if (value[1] !== undefined && value[1] !== null && value[1] !== '')
|
|
288
|
-
filters.push({ field: fieldCode, operator: 'lte', value: value[1] });
|
|
289
|
-
} else if (Array.isArray(value) && ['multi', 'directory-user', 'directory-department', 'resource'].includes(field?.widget || '')) {
|
|
290
|
-
filters.push({ field: fieldCode, operator: 'cs', value });
|
|
291
|
-
} else {
|
|
292
|
-
filters.push({ field: fieldCode, operator: 'eq', value });
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
return filters;
|
|
296
|
-
};
|
|
297
520
|
return {
|
|
298
521
|
async list(query: GenericResourceQuery) {
|
|
299
522
|
const fallbackSort = surface.list?.defaultSort || {
|
|
@@ -302,9 +525,10 @@ export function createNativeResourceClient(code: string, surface: DataResourceSu
|
|
|
302
525
|
};
|
|
303
526
|
const requestedSort = query.sort || fallbackSort;
|
|
304
527
|
const sort = declaredFields.has(requestedSort.field) ? requestedSort : fallbackSort;
|
|
528
|
+
const where = buildResourceWhere(code, surface, query);
|
|
305
529
|
const body: DataQuery = {
|
|
306
530
|
schemaVersion: SCHEMA_VERSIONS.dataQuery,
|
|
307
|
-
|
|
531
|
+
...(where ? { where } : {}),
|
|
308
532
|
order: [{ field: sort.field, direction: sort.order || 'asc' }],
|
|
309
533
|
limit: query.pageSize,
|
|
310
534
|
offset: (query.page - 1) * query.pageSize,
|
|
@@ -318,6 +542,28 @@ export function createNativeResourceClient(code: string, surface: DataResourceSu
|
|
|
318
542
|
});
|
|
319
543
|
return { rows: page.items, total: page.total };
|
|
320
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
|
+
},
|
|
321
567
|
async get(id: string) {
|
|
322
568
|
const record = await request<DataRecord<Record<string, unknown>>>(
|
|
323
569
|
`${base}/records/${encodeURIComponent(id)}?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
|
|
@@ -418,6 +664,10 @@ export function dataFileContentUrl(resource: string, fileId: string, disposition
|
|
|
418
664
|
return `${dataBase(resource)}/files/${encodeURIComponent(fileId)}/content?${query}`;
|
|
419
665
|
}
|
|
420
666
|
|
|
667
|
+
export function dataRichTextImageSource(resource: string, fileId: string) {
|
|
668
|
+
return `${dataBase(resource)}/files/${encodeURIComponent(fileId)}/content?disposition=inline`;
|
|
669
|
+
}
|
|
670
|
+
|
|
421
671
|
export async function loadDataFilePreview(resource: string, fileId: string) {
|
|
422
672
|
return request<DataFilePreview>(
|
|
423
673
|
`${dataBase(resource)}/files/${encodeURIComponent(fileId)}/preview?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
|
|
@@ -425,10 +675,34 @@ export async function loadDataFilePreview(resource: string, fileId: string) {
|
|
|
425
675
|
}
|
|
426
676
|
|
|
427
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
|
+
}
|
|
428
682
|
const response = await fetch(dataFileContentUrl(resource, fileId, 'inline'), {
|
|
429
683
|
credentials: 'include',
|
|
430
|
-
headers
|
|
684
|
+
headers,
|
|
431
685
|
});
|
|
432
686
|
if (!response.ok) throw new Error(`FILE_CONTENT_READ_FAILED: HTTP_${response.status}`);
|
|
433
687
|
return response.blob();
|
|
434
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
|
+
}
|