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.
Files changed (65) hide show
  1. package/package.json +4 -4
  2. package/template/AGENTS.md +4 -4
  3. package/template/README.md +4 -4
  4. package/template/apps/server/package.json +1 -1
  5. package/template/apps/server/src/app.module.ts +14 -1
  6. package/template/apps/server/test/smoke.test.ts +3 -1
  7. package/template/apps/web/data-api-live.e2e.html +15 -0
  8. package/template/apps/web/e2e/data-api-live-fixture.tsx +141 -0
  9. package/template/apps/web/e2e/data-api-live.spec.ts +138 -0
  10. package/template/apps/web/e2e/field-protocol-fixture.tsx +280 -0
  11. package/template/apps/web/e2e/field-protocol.spec.ts +100 -0
  12. package/template/apps/web/e2e/resources.spec.ts +258 -20
  13. package/template/apps/web/field-protocol.e2e.html +12 -0
  14. package/template/apps/web/package.json +1 -1
  15. package/template/apps/web/playwright.config.ts +3 -1
  16. package/template/apps/web/scripts/check.mjs +8 -5
  17. package/template/apps/web/scripts/verify-build.mjs +1 -1
  18. package/template/apps/web/src/AuthoritativeSelector.tsx +165 -132
  19. package/template/apps/web/src/FilePreviewPage.tsx +30 -9
  20. package/template/apps/web/src/RoleSubjectSelect.tsx +128 -0
  21. package/template/apps/web/src/Shell.tsx +93 -6
  22. package/template/apps/web/src/components/platform-fields/AddressField.tsx +338 -0
  23. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +25 -6
  24. package/template/apps/web/src/components/platform-fields/CascadeField.tsx +49 -0
  25. package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +148 -0
  26. package/template/apps/web/src/components/platform-fields/JsonField.tsx +77 -0
  27. package/template/apps/web/src/components/platform-fields/LocationField.tsx +164 -0
  28. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +19 -44
  29. package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +67 -0
  30. package/template/apps/web/src/components/platform-fields/RichTextField.tsx +196 -0
  31. package/template/apps/web/src/components/platform-fields/SignatureField.tsx +315 -0
  32. package/template/apps/web/src/components/platform-fields/SubtableField.tsx +553 -0
  33. package/template/apps/web/src/components/platform-fields/address-value.ts +80 -0
  34. package/template/apps/web/src/components/platform-fields/cascade-value.ts +66 -0
  35. package/template/apps/web/src/components/platform-fields/directory-value.ts +53 -0
  36. package/template/apps/web/src/components/platform-fields/field-form-codec.ts +98 -0
  37. package/template/apps/web/src/components/platform-fields/location-value.ts +87 -0
  38. package/template/apps/web/src/components/platform-fields/resource-query.ts +131 -0
  39. package/template/apps/web/src/components/platform-fields/rich-text-value.ts +59 -0
  40. package/template/apps/web/src/components/platform-fields/subtable-value.ts +187 -0
  41. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +194 -108
  42. package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +8 -1
  43. package/template/apps/web/src/components/resource/SurfaceFields.tsx +385 -79
  44. package/template/apps/web/src/components/resource/generated-resource-definition.ts +18 -0
  45. package/template/apps/web/src/components/resource/resource-import.ts +4 -4
  46. package/template/apps/web/src/data-provider.ts +59 -34
  47. package/template/apps/web/src/platform-client.ts +341 -136
  48. package/template/apps/web/src/runtime.tsx +156 -17
  49. package/template/apps/web/src/styles.css +264 -0
  50. package/template/apps/web/test/address-field.test.ts +66 -0
  51. package/template/apps/web/test/cascade-value.test.ts +68 -0
  52. package/template/apps/web/test/contracts.test.ts +29 -5
  53. package/template/apps/web/test/directory-value.test.ts +55 -0
  54. package/template/apps/web/test/field-bounds.test.ts +17 -0
  55. package/template/apps/web/test/field-form-codec.test.ts +85 -0
  56. package/template/apps/web/test/location-field.test.ts +83 -0
  57. package/template/apps/web/test/resource-import.test.ts +8 -7
  58. package/template/apps/web/test/resource-query.test.ts +144 -0
  59. package/template/apps/web/test/rich-json-field.test.ts +53 -0
  60. package/template/apps/web/test/signature-serial-field.test.ts +40 -0
  61. package/template/apps/web/test/subtable-value.test.ts +144 -0
  62. package/template/openxiangda.config.ts +1 -1
  63. package/template/package.json +3 -3
  64. package/template/packages/contracts/src/generated.ts +95 -0
  65. 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 CurrentUser,
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
- authorization?: {
90
- mode: 'union' | 'focused_role';
91
- focusedRoleCode: string | null;
92
- availableRoles: Array<{ code: string; name: string }>;
93
- hasAppSuperAdminGrant: boolean;
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
- type DeployedCurrent = CurrentUser;
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 loadCurrentIdentity(): Promise<RuntimeIdentity> {
254
+ export async function loadRuntimeAuthorization(): Promise<RuntimeAuthorization> {
117
255
  const mount = runtimeMount();
118
256
  if (mount) {
119
- const current = await request<DeployedCurrent>(
120
- `${nativeBase()}/current-user?environmentKey=${encodeURIComponent(mount.environmentKey)}`
257
+ const context = await request<RoleSessionContext>(
258
+ `${nativeBase()}/authz/role-session/context?environmentKey=${encodeURIComponent(mount.environmentKey)}`
121
259
  );
122
- if (
123
- current.schemaVersion !== SCHEMA_VERSIONS.currentUser ||
124
- current.appCode !== mount.appCode ||
125
- current.environment.key !== mount.environmentKey ||
126
- current.principal.type !== 'user'
127
- ) {
128
- throw new Error('OPENXIANGDA_DEPLOYED_CURRENT_USER_INVALID');
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
- activeIdentity = {
131
- ...current.principal,
132
- environment: current.environment,
133
- authorization: current.authorization,
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
- const current = await request<ConnectedCurrent>(`/service/openxiangda-api/v2/applications/${applicationCode()}/dev-sessions/current`);
138
- activeIdentity = { ...current.principal, environment: current.environment };
139
- return activeIdentity;
313
+ publishRoleSessionInvalidation();
314
+ return authorization;
140
315
  }
141
316
 
142
- const AUTHORIZATION_CHANNEL = 'openxiangda.authorization-context/v2';
143
- const AUTHORIZATION_STORAGE_KEY =
144
- 'openxiangda.authorization-context-invalidated/v2';
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 AuthorizationInvalidation = {
147
- type: 'authorization-context-invalidated';
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 validAuthorizationInvalidation(
328
+ function validRoleSessionInvalidation(
155
329
  value: unknown
156
- ): value is AuthorizationInvalidation {
330
+ ): value is RoleSessionInvalidation {
157
331
  if (!value || typeof value !== 'object') return false;
158
- const candidate = value as Partial<AuthorizationInvalidation>;
332
+ const candidate = value as Partial<RoleSessionInvalidation>;
159
333
  return (
160
- candidate.type === 'authorization-context-invalidated' &&
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
- export function subscribeAuthorizationInvalidation(listener: () => void) {
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
- validAuthorizationInvalidation(value) &&
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(AUTHORIZATION_CHANNEL);
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 !== AUTHORIZATION_STORAGE_KEY || !event.newValue) return;
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(resourceCode: string, labelField: string, options: { keyword: string; cursor?: string }) {
266
- const pageSize = 20;
267
- const offset = options.cursor ? Number(options.cursor) : 0;
268
- const query: DataQuery = {
269
- schemaVersion: SCHEMA_VERSIONS.dataQuery,
270
- filters: options.keyword.trim()
271
- ? [
272
- {
273
- field: labelField,
274
- operator: 'ilike',
275
- value: `%${options.keyword.trim()}%`,
276
- },
277
- ]
278
- : [],
279
- order: [{ field: labelField, direction: 'asc' }],
280
- limit: pageSize,
281
- offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0,
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
- const page = await request<DataPage<Record<string, unknown>>>(`${dataBase(resourceCode)}/query`, {
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
- filters: filtersFor(query),
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: { accept: 'application/octet-stream,*/*' },
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
+ }